zen-gitsync 2.14.2 → 2.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/src/ui/public/assets/{AppVersionBadge-M9ydXpd9.js → AppVersionBadge-BoAhI0Kt.js} +2 -2
  3. package/src/ui/public/assets/BranchSelector--8Qfb75A.js +1 -0
  4. package/src/ui/public/assets/CommitForm-CobjwXah.js +6 -0
  5. package/src/ui/public/assets/ConsoleView-B2WZRKsP.css +1 -0
  6. package/src/ui/public/assets/ConsoleView-HcfiYLnM.js +17 -0
  7. package/src/ui/public/assets/CustomCommandManager-DQG4wRU7.js +1 -0
  8. package/src/ui/public/assets/{EditorView-rGLnccH3.js → EditorView-CAjuz7yY.js} +0 -0
  9. package/src/ui/public/assets/{FlowExecutionViewer-B4BADP8e.js → FlowExecutionViewer-C8H9uoZL.js} +1 -1
  10. package/src/ui/public/assets/FlowOrchestrationWorkspace-C2RMFB-p.js +21 -0
  11. package/src/ui/public/assets/{LogList-7agk5Obq.js → LogList-8qSNrgT7.js} +4 -4
  12. package/src/ui/public/assets/{MindmapView-mOFG9TmR.js → MindmapView-C5pLBOs_.js} +1 -1
  13. package/src/ui/public/assets/{MonitorView-Dlpgy8Bb.js → MonitorView-Bijvi8pi.js} +1 -1
  14. package/src/ui/public/assets/ProjectStartupButton-Cb-RAZqA.js +1 -0
  15. package/src/ui/public/assets/{RemoteRepoCard-CFKl6CB9.js → RemoteRepoCard-DjfI188H.js} +1 -1
  16. package/src/ui/public/assets/{SourceMapView-Dsc_a3t7.js → SourceMapView-DbdbojUR.js} +2 -2
  17. package/src/ui/public/assets/{SvgIcon-Cw2mL_zx.js → SvgIcon-BVBpj6nK.js} +1 -1
  18. package/src/ui/public/assets/TemplateManager-C0rm8xfC.css +1 -0
  19. package/src/ui/public/assets/TemplateManager-CSI1wQZY.js +1 -0
  20. package/src/ui/public/assets/{UserInputNode-yBT2005h.js → UserInputNode-8ocSzj-6.js} +1 -1
  21. package/src/ui/public/assets/WorkbenchView-CvHPPs8Q.css +1 -0
  22. package/src/ui/public/assets/{WorkbenchView-CiG-qMl7.js → WorkbenchView-DAXAzJTK.js} +4 -4
  23. package/src/ui/public/assets/_plugin-vue_export-helper-Uwc4Esmp.js +15 -0
  24. package/src/ui/public/assets/commandParser-wwLxoJ92.js +1 -0
  25. package/src/ui/public/assets/{configStore-Cq6Qwn35.js → configStore-BHFJlgWp.js} +1 -1
  26. package/src/ui/public/assets/element-plus-R1nMzM9j.css +1 -0
  27. package/src/ui/public/assets/{flow-mindmap-CAkGY2ir.js → flow-mindmap-C0IUPqjN.js} +1 -1
  28. package/src/ui/public/assets/index-BZk-ldXr.css +1 -0
  29. package/src/ui/public/assets/index-Bl8fyL3X.js +23 -0
  30. package/src/ui/public/assets/{vendor-BuiZXcjj.js → vendor-0hkrGFDb.js} +1 -1
  31. package/src/ui/public/assets/{vue-flow-C6RXt-U3.js → vue-flow-CkwNeuGO.js} +1 -1
  32. package/src/ui/public/index.html +9 -11
  33. package/src/ui/server/index.js +632 -625
  34. package/src/ui/server/middleware/errorHandler.js +84 -0
  35. package/src/ui/server/middleware/errorHandler.test.js +114 -0
  36. package/src/ui/server/routes/branchStatus.test.js +37 -0
  37. package/src/ui/server/routes/exec.js +0 -21
  38. package/src/ui/server/routes/gitOps.js +62 -0
  39. package/src/ui/server/routes/monitor.js +42 -26
  40. package/src/ui/server/routes/npm.js +1387 -1327
  41. package/src/ui/server/routes/terminal.js +318 -320
  42. package/src/ui/server/routes/workbench/attachmentUtils.js +76 -0
  43. package/src/ui/server/routes/workbench/index.js +1680 -0
  44. package/src/ui/server/routes/workbench/instructionStore.js +175 -0
  45. package/src/ui/server/routes/workbench/jobStore.js +225 -0
  46. package/src/ui/server/routes/workbench/jsonParse.js +219 -0
  47. package/src/ui/server/routes/workbench/llmClient.js +214 -0
  48. package/src/ui/server/routes/workbench/projectScan.js +191 -0
  49. package/src/ui/server/routes/workbench/sessionStore.js +126 -0
  50. package/src/ui/server/routes/workbench/shared.js +115 -0
  51. package/src/ui/server/routes/workbench/taskRunner.js +493 -0
  52. package/src/ui/server/routes/workbench.js +16 -3523
  53. package/src/utils/index.js +10 -3
  54. package/src/ui/public/assets/BranchSelector-scIk6OeM.js +0 -1
  55. package/src/ui/public/assets/CommandConsole-BikQuarw.js +0 -17
  56. package/src/ui/public/assets/CommandConsole-Da3j09oc.css +0 -1
  57. package/src/ui/public/assets/CommitForm-DuvNxUdP.js +0 -6
  58. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  59. package/src/ui/public/assets/CommonDialog-G17HYMln.js +0 -1
  60. package/src/ui/public/assets/FlowOrchestrationWorkspace-C3l3Jco7.js +0 -21
  61. package/src/ui/public/assets/ProjectStartupButton-CgS14qyg.js +0 -1
  62. package/src/ui/public/assets/WorkbenchView-OmJ1eM5g.css +0 -1
  63. package/src/ui/public/assets/_plugin-vue_export-helper-BMXWW0fG.js +0 -15
  64. package/src/ui/public/assets/element-plus-D06ZgZjR.css +0 -1
  65. package/src/ui/public/assets/index-CPqKvZBK.js +0 -23
  66. package/src/ui/public/assets/index-gbRjS0sL.css +0 -1
  67. /package/src/ui/public/assets/{element-plus-B6Owr5jY.js → element-plus-tbZnDtyd.js} +0 -0
@@ -1,979 +1,1039 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- import express from 'express';
16
- import logger from '../utils/logger.js'
17
- import fs from 'fs/promises';
18
- import fsSync from 'fs';
19
- import path from 'path';
20
- import os from 'os';
21
- import { exec, spawn } from 'child_process';
22
- import https from 'https';
23
- import { fileURLToPath } from 'url';
24
- import { getRegistryPath } from '../utils/instanceRegistry.js';
25
-
26
- const __filename = fileURLToPath(import.meta.url);
27
- const __dirname = path.dirname(__filename);
28
-
29
- export function registerNpmRoutes({
30
- app,
31
- getCurrentProjectPath
32
- }) {
33
- // 标记是否有升级任务在跑(防并发)
34
- let activeUpgrade = false;
35
- // 标记是否有重启任务在跑(防并发)
36
- let activeRestart = false;
37
- // 自拉起重启相关常量
38
- const RESTART_TIMEOUT_MS = 8000; // 子进程必须在此时间内注册到 instanceRegistry
39
- const REGISTRY_POLL_MS = 250; // 轮询注册表文件间隔
40
- const EXIT_DELAY_MS = 100; // 子进程就绪后给前端的最后缓冲时间
41
-
42
- // ========== 应用自升级相关 API ==========
43
-
44
- // 解析"自拉起"要 spawn 的入口绝对路径。
45
- // 优先 process.argv[1](开发态 server.js / 生产态 src/gitCommit.js 都是绝对路径),
46
- // 但 launch.json 的 backend 是 `node -e "...process.argv = [...]; require('./server.js')"`,
47
- // 这种篡改出来的 argv[1] 是相对路径 "server.js",不能直接 spawn。
48
- // 回退: 用 __dirname 推导 src/ui/server/index.js 的同级 server.js(仓库根)
49
- function resolveRestartEntry() {
50
- const arg = process.argv[1]
51
- if (arg && path.isAbsolute(arg) && fsSync.existsSync(arg)) {
52
- return { entry: arg, args: process.argv.slice(2) }
53
- }
54
- // 回退到仓库根的 server.js(开发态入口),保留原 PORT 等环境变量
55
- const fallback = path.resolve(__dirname, '../../../..', 'server.js')
56
- if (fsSync.existsSync(fallback)) {
57
- // 透传用户原始 argv[2..](--no-open 等);如果原 argv[1] 是 "server.js" 这类相对路径,
58
- // 用户大概率没传任何 args,所以空数组也合理
59
- const originalArgs = (arg && !path.isAbsolute(arg)) ? process.argv.slice(2) : []
60
- return { entry: fallback, args: originalArgs }
61
- }
62
- return null
63
- }
64
-
65
- // 当前安装的版本(从外层 package.json 读取)
66
- function getCurrentVersion() {
67
- try {
68
- // src/ui/server/routes/npm.js -> ../../../../package.json
69
- const pkgPath = path.resolve(__dirname, '../../../..', 'package.json')
70
- if (fsSync.existsSync(pkgPath)) {
71
- const pkg = JSON.parse(fsSync.readFileSync(pkgPath, 'utf8'))
72
- return pkg.version || '0.0.0'
73
- }
74
- } catch (err) {
75
- logger.error('读取本地 package.json 版本失败:', err)
76
- }
77
- return '0.0.0'
78
- }
79
-
80
- // 简单的 semver 比较:a > b 返回 1, a < b 返回 -1, 相等返回 0
81
- function compareSemver(a, b) {
82
- const pa = a.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
83
- const pb = b.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
84
- for (let i = 0; i < 3; i++) {
85
- if ((pa[i] || 0) > (pb[i] || 0)) return 1
86
- if ((pa[i] || 0) < (pb[i] || 0)) return -1
87
- }
88
- return 0
89
- }
90
-
91
- // 缓存 npm registry latest 结果 10 分钟
92
- let latestVersionCache = { value: null, at: 0 }
93
- const LATEST_TTL_MS = 10 * 60 * 1000
94
-
95
- function fetchLatestFromRegistry() {
96
- return new Promise((resolve, reject) => {
97
- const req = https.get(
98
- 'https://registry.npmjs.org/zen-gitsync/latest',
99
- { timeout: 5000, headers: { 'User-Agent': 'zen-gitsync-app' } },
100
- (res) => {
101
- let data = ''
102
- res.on('data', chunk => { data += chunk })
103
- res.on('end', () => {
104
- if (res.statusCode !== 200) {
105
- return reject(new Error(`registry 返回状态 ${res.statusCode}`))
106
- }
107
- try {
108
- const pkg = JSON.parse(data)
109
- resolve(pkg.version || null)
110
- } catch (e) {
111
- reject(new Error('解析 registry 响应失败'))
112
- }
113
- })
114
- }
115
- )
116
- req.on('timeout', () => {
117
- req.destroy(new Error('registry 请求超时'))
118
- })
119
- req.on('error', err => reject(err))
120
- })
121
- }
122
-
123
- async function getLatestVersion() {
124
- const now = Date.now()
125
- if (latestVersionCache.value && now - latestVersionCache.at < LATEST_TTL_MS) {
126
- return latestVersionCache.value
127
- }
128
- const v = await fetchLatestFromRegistry()
129
- latestVersionCache = { value: v, at: now }
130
- return v
131
- }
132
-
133
- // GET /api/app-version
134
- app.get('/api/app-version', async (_req, res) => {
135
- const current = getCurrentVersion()
136
- try {
137
- const latest = await getLatestVersion()
138
- const hasUpdate = !!latest && compareSemver(latest, current) > 0
139
- res.json({ success: true, current, latest, hasUpdate })
140
- } catch (err) {
141
- // 拉取失败时仍返回当前版本,前端据此决定是否显示升级按钮
142
- res.json({
143
- success: false,
144
- current,
145
- latest: null,
146
- hasUpdate: false,
147
- error: err.message
148
- })
149
- }
150
- })
151
-
152
- // POST /api/app-upgrade - 流式 NDJSON: { type: 'stdout'|'stderr'|'done'|'error', ... }
153
- app.post('/api/app-upgrade', (req, res) => {
154
- // 防并发:同一时刻只允许一个升级任务
155
- if (activeUpgrade) {
156
- res.status(409).json({ success: false, error: '已有升级任务在进行中' })
157
- return
158
- }
159
-
160
- res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8')
161
- res.setHeader('Cache-Control', 'no-cache')
162
- res.setHeader('Connection', 'keep-alive')
163
- res.flushHeaders?.()
164
-
165
- const send = (obj) => {
166
- res.write(JSON.stringify(obj) + '\n')
167
- }
168
-
169
- // Windows 上 npm install -g 通常不需要管理员;*nix 需要 sudo。
170
- // 用 sudo -n(非交互式)检测是否可免密,失败时让用户用 sudo 重启 GUI
171
- const isWin = process.platform === 'win32'
172
- const npmCmd = isWin ? 'npm.cmd' : 'npm'
173
- const args = ['install', '-g', 'zen-gitsync', '--registry', 'https://registry.npmjs.org/']
174
- const cmd = isWin ? npmCmd : 'sudo'
175
- const finalArgs = isWin ? args : ['-n', npmCmd, ...args]
176
-
177
- activeUpgrade = send
178
- const cleanup = () => { activeUpgrade = null }
179
-
180
- let child
181
- try {
182
- child = spawn(cmd, finalArgs, {
183
- env: { ...process.env, FORCE_COLOR: '0' },
184
- windowsHide: true,
185
- // Windows 下调用 .cmd 必须 shell:true
186
- shell: isWin
187
- })
188
- } catch (err) {
189
- send({ type: 'error', message: `启动升级进程失败: ${err.message}` })
190
- send({ type: 'done', code: -1 })
191
- res.end()
192
- cleanup()
193
- return
194
- }
195
-
196
- send({ type: 'stdout', message: `$ ${cmd} ${finalArgs.join(' ')}\n` })
197
-
198
- child.stdout.on('data', (buf) => send({ type: 'stdout', message: buf.toString() }))
199
- child.stderr.on('data', (buf) => send({ type: 'stderr', message: buf.toString() }))
200
-
201
- child.on('error', (err) => {
202
- send({ type: 'error', message: `升级进程错误: ${err.message}` })
203
- })
204
-
205
- child.on('close', (code) => {
206
- if (code === 0) {
207
- send({ type: 'stdout', message: '\n✅ 升级完成,新版本已全局安装。\n' })
208
- // 清理缓存,强制下次重新拉 latest
209
- latestVersionCache = { value: null, at: 0 }
210
- } else {
211
- let hint = ''
212
- if (!isWin) {
213
- hint = '(提示:在 macOS/Linux 上全局安装需要 sudo,请用管理员权限重启 GUI 后再试)'
214
- }
215
- send({ type: 'error', message: `\n升级失败,退出码 ${code}${hint ? ' ' + hint : ''}\n` })
216
- }
217
- send({ type: 'done', code })
218
- res.end()
219
- cleanup()
220
- })
221
- })
222
-
223
-
224
- // 同步读取 instanceRegistry 文件并返回 Map<pid, entry>
225
- // npm.js 在自拉起重启场景下需要轮询此文件判断子进程是否已绑定端口
226
- function readRegistrySync(p) {
227
- const m = new Map();
228
- try {
229
- const raw = fsSync.readFileSync(p, 'utf-8');
230
- const obj = JSON.parse(raw);
231
- const instances = (obj && obj.instances) || {};
232
- for (const [pidStr, entry] of Object.entries(instances)) {
233
- const pid = Number(pidStr);
234
- if (Number.isFinite(pid) && entry && typeof entry === 'object') {
235
- m.set(pid, entry);
236
- }
237
- }
238
- } catch (_) {
239
- // 文件不存在或 JSON 损坏 → 返回空 Map,调用方按"还没注册"处理
240
- }
241
- return m;
242
- }
243
-
244
- // POST /api/app-restart - 自拉起重启:spawn 当前进程入口 + 轮询 instanceRegistry
245
- // 拿到子进程端口后通过 NDJSON 流告知前端;旧进程再退出
246
- app.post('/api/app-restart', (_req, res) => {
247
- if (activeRestart) {
248
- res.status(409).json({ success: false, error: '已有重启任务在进行中' });
249
- return;
250
- }
251
- activeRestart = true;
252
-
253
- res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
254
- res.setHeader('Cache-Control', 'no-cache');
255
- res.setHeader('Connection', 'keep-alive');
256
- res.flushHeaders?.();
257
-
258
- const send = (obj) => { try { res.write(JSON.stringify(obj) + '\n') } catch (_) {} };
259
- const finish = () => { try { res.end() } catch (_) {} };
260
- const release = () => { activeRestart = false };
261
-
262
- // 1. 记录当前所有 instance,后续用"出现的新 pid"判定子进程
263
- const registryPath = getRegistryPath();
264
- const before = readRegistrySync(registryPath);
265
- const start = Date.now();
266
- const spawnAt = Date.now();
267
-
268
- // 2. 准备子进程 ready 信号文件 —— startServerOnAvailablePort 在 listen 成功后会写入
269
- // (instanceRegistry 在 EPERM 环境下不可用,这是回退通道)
270
- const notifyPath = path.join(
271
- os.tmpdir(),
272
- `zen-gitsync-restart-${process.pid}-${Date.now()}.port`
273
- );
274
- try { fsSync.unlinkSync(notifyPath) } catch (_) {} // 清掉旧同名文件
275
-
276
- const entryInfo = resolveRestartEntry();
277
- if (!entryInfo) {
278
- send({ type: 'error', message: '无法解析重启入口(process.argv[1] 既不是绝对路径,仓库根也没有 server.js)' });
279
- finish();
280
- release();
281
- return;
282
- }
283
-
284
- let child;
285
- try {
286
- // spawn 当前进程当时是怎么被 node 启的同一个入口(开发态 server.js / 生产态 gitCommit.js)
287
- // detached: true + stdio: 'ignore' + child.unref() → Windows 上父进程 exit 不会带走子进程
288
- child = spawn(process.execPath, [entryInfo.entry, ...entryInfo.args], {
289
- detached: true,
290
- stdio: 'ignore',
291
- windowsHide: true,
292
- env: { ...process.env, ZEN_RESTART_NOTIFY_PATH: notifyPath }
293
- });
294
- child.unref();
295
- } catch (err) {
296
- send({ type: 'error', message: `拉起子进程失败: ${err?.message || err}` });
297
- try { fsSync.unlinkSync(notifyPath) } catch (_) {}
298
- finish();
299
- release();
300
- return;
301
- }
302
-
303
- send({ type: 'stdout', message: `[restart] 已在 PID ${child.pid} 拉起子进程 (${entryInfo.entry}),等待端口就绪…\n` });
304
-
305
- const poll = setInterval(() => {
306
- if (Date.now() - start > RESTART_TIMEOUT_MS) {
307
- clearInterval(poll);
308
- try { child.kill('SIGTERM') } catch (_) {}
309
- send({ type: 'error', message: `子进程 ${RESTART_TIMEOUT_MS / 1000}s 内未就绪(未读到 ${notifyPath}),已 kill` });
310
- send({ type: 'timeout' });
311
- finish();
312
- try { fsSync.unlinkSync(notifyPath) } catch (_) {}
313
- // 关键:超时分支旧进程不退出,用户仍可继续手动刷新页面
314
- release();
315
- return;
316
- }
317
-
318
- // 优先判定:子进程是否写了 notify 文件(最可靠,不走 instanceRegistry)
319
- let notifiedPort = null;
320
- try {
321
- const raw = fsSync.readFileSync(notifyPath, 'utf8').trim();
322
- const p = Number(raw);
323
- if (Number.isInteger(p) && p > 0 && p < 65536) notifiedPort = p;
324
- } catch (_) {}
325
-
326
- // 兜底:instanceRegistry 里出现的新 pid + port(用于没有 savePort 的环境)
327
- let registryPort = null;
328
- if (!notifiedPort) {
329
- const after = readRegistrySync(registryPath);
330
- for (const [pid, entry] of after) {
331
- if (
332
- !before.has(pid) &&
333
- Number.isInteger(entry.port) && entry.port > 0 &&
334
- (entry.lastHeartbeat ?? entry.startedAt ?? 0) >= spawnAt
335
- ) {
336
- registryPort = entry.port;
337
- break;
338
- }
339
- }
340
- }
341
-
342
- const newPort = notifiedPort ?? registryPort;
343
- if (newPort) {
344
- clearInterval(poll);
345
- // EXIT_DELAY_MS 让新进程稳定,再通知前端 + 旧进程退出
346
- setTimeout(() => {
347
- send({ type: 'ready', port: newPort });
348
- finish();
349
- try { fsSync.unlinkSync(notifyPath) } catch (_) {}
350
- logger.info('[app-restart] 子进程已就绪,退出当前进程');
351
- process.exit(0);
352
- }, EXIT_DELAY_MS);
353
- return;
354
- }
355
- }, REGISTRY_POLL_MS);
356
- });
357
-
358
-
359
- // 读取 package.json 文件内容
360
- app.post('/api/read-package-json', express.json(), async (req, res) => {
361
- try {
362
- const { packageJsonPath } = req.body
363
-
364
- // 确定 package.json 的路径
365
- let pkgPath
366
- if (packageJsonPath && packageJsonPath.trim()) {
367
- pkgPath = path.isAbsolute(packageJsonPath)
368
- ? packageJsonPath
369
- : path.join(getCurrentProjectPath(), packageJsonPath)
370
- } else {
371
- pkgPath = path.join(getCurrentProjectPath(), 'package.json')
372
- }
373
-
374
- // 检查文件是否存在
375
- try {
376
- const stats = await fs.stat(pkgPath)
377
- if (stats.isDirectory()) {
378
- pkgPath = path.join(pkgPath, 'package.json')
379
- }
380
- await fs.access(pkgPath)
381
- } catch (err) {
382
- return res.status(404).json({
383
- success: false,
384
- error: `未找到 package.json 文件: ${pkgPath}`
385
- })
386
- }
387
-
388
- // 读取 package.json
389
- const pkgContent = await fs.readFile(pkgPath, 'utf8')
390
- const pkg = JSON.parse(pkgContent)
391
-
392
- res.json({
393
- success: true,
394
- dependencies: pkg.dependencies || {},
395
- devDependencies: pkg.devDependencies || {},
396
- peerDependencies: pkg.peerDependencies || {},
397
- peerDependenciesMeta: pkg.peerDependenciesMeta || {},
398
- version: pkg.version
399
- })
400
- } catch (error) {
401
- res.status(500).json({ success: false, error: error.message })
402
- }
403
- })
404
-
405
- // 版本号递增或依赖版本修改
406
- app.post('/api/version-bump', express.json(), async (req, res) => {
407
- try {
408
- const { bumpType, packageJsonPath, versionTarget, dependencyName, dependencyVersion, dependencyVersionBump, dependencyType, versionValue } = req.body
409
-
410
- // 确定 package.json 的路径
411
- let pkgPath
412
- if (packageJsonPath && packageJsonPath.trim()) {
413
- pkgPath = path.isAbsolute(packageJsonPath)
414
- ? packageJsonPath
415
- : path.join(getCurrentProjectPath(), packageJsonPath)
416
- } else {
417
- pkgPath = path.join(getCurrentProjectPath(), 'package.json')
418
- }
419
-
420
- // 检查文件是否存在(使用 Node.js 原生方法)
421
- try {
422
- await fs.access(pkgPath)
423
- } catch (err) {
424
- return res.status(404).json({
425
- success: false,
426
- error: `未找到 package.json 文件: ${pkgPath}`
427
- })
428
- }
429
-
430
- // 读取 package.json
431
- const pkgContent = await fs.readFile(pkgPath, 'utf8')
432
- const pkg = JSON.parse(pkgContent)
433
-
434
- // 判断是修改 version 还是 dependency
435
- if (versionTarget === 'dependency') {
436
- // 修改依赖版本
437
- const depType = dependencyType || 'dependencies'
438
-
439
- if (!pkg[depType]) {
440
- return res.status(400).json({
441
- success: false,
442
- error: `package.json 中未找到 ${depType} 字段`
443
- })
444
- }
445
-
446
- if (!pkg[depType][dependencyName]) {
447
- return res.status(400).json({
448
- success: false,
449
- error: `在 ${depType} 中未找到依赖包: ${dependencyName}`
450
- })
451
- }
452
-
453
- const oldVersion = pkg[depType][dependencyName]
454
- let newVersion
455
-
456
- // 判断是自动递增还是手动输入
457
- if (dependencyVersionBump) {
458
- // 自动递增模式:解析当前版本号并递增
459
- // 提取版本号中的数字部分(去除 ^, ~, >=, 等前缀)
460
- const versionMatch = oldVersion.match(/(\^|~|>=|>|<=|<)?(\d+\.\d+\.\d+)/)
461
- if (!versionMatch) {
462
- return res.status(400).json({
463
- success: false,
464
- error: `无法解析版本号: ${oldVersion},应为 x.y.z 格式(可带 ^, ~ 等前缀)`
465
- })
466
- }
467
-
468
- const prefix = versionMatch[1] || ''
469
- const versionNumber = versionMatch[2]
470
- const versionParts = versionNumber.split('.').map(Number)
471
-
472
- if (versionParts.length !== 3 || versionParts.some(isNaN)) {
473
- return res.status(400).json({
474
- success: false,
475
- error: `无效的版本号格式: ${versionNumber}`
476
- })
477
- }
478
-
479
- // 根据类型递增版本号
480
- let [major, minor, patch] = versionParts
481
- if (dependencyVersionBump === 'major') {
482
- major += 1
483
- minor = 0
484
- patch = 0
485
- } else if (dependencyVersionBump === 'minor') {
486
- minor += 1
487
- patch = 0
488
- } else { // patch
489
- patch += 1
490
- }
491
-
492
- newVersion = `${prefix}${major}.${minor}.${patch}`
493
- } else {
494
- // 手动输入模式
495
- newVersion = dependencyVersion
496
- }
497
-
498
- pkg[depType][dependencyName] = newVersion
499
-
500
- // 写回 package.json(保持格式化)
501
- await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
502
-
503
- res.json({
504
- success: true,
505
- oldVersion,
506
- newVersion,
507
- filePath: pkgPath,
508
- dependencyName,
509
- dependencyType: depType
510
- })
511
- } else {
512
- // 修改 version 字段
513
- if (!pkg.version) {
514
- return res.status(400).json({
515
- success: false,
516
- error: 'package.json 中未找到 version 字段'
517
- })
518
- }
519
-
520
- const oldVersion = pkg.version
521
-
522
- // 直接设置版本号(来自版本节点输入配置)
523
- const direct = typeof versionValue === 'string' ? versionValue.trim() : ''
524
- if (direct) {
525
- const versionParts = direct.split('.').map(Number)
526
- if (versionParts.length !== 3 || versionParts.some(isNaN)) {
527
- return res.status(400).json({
528
- success: false,
529
- error: `无效的版本号格式: ${direct},应为 x.y.z 格式`
530
- })
531
- }
532
-
533
- pkg.version = direct
534
- await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
535
-
536
- return res.json({
537
- success: true,
538
- oldVersion,
539
- newVersion: direct,
540
- filePath: pkgPath
541
- })
542
- }
543
-
544
- // 解析版本号
545
- const versionParts = oldVersion.split('.').map(Number)
546
- if (versionParts.length !== 3 || versionParts.some(isNaN)) {
547
- return res.status(400).json({
548
- success: false,
549
- error: `无效的版本号格式: ${oldVersion},应为 x.y.z 格式`
550
- })
551
- }
552
-
553
- // 根据类型递增版本号
554
- let [major, minor, patch] = versionParts
555
- if (bumpType === 'major') {
556
- major += 1
557
- minor = 0
558
- patch = 0
559
- } else if (bumpType === 'minor') {
560
- minor += 1
561
- patch = 0
562
- } else { // patch
563
- patch += 1
564
- }
565
-
566
- const newVersion = `${major}.${minor}.${patch}`
567
- pkg.version = newVersion
568
-
569
- // 写回 package.json(保持格式化)
570
- await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
571
-
572
- res.json({
573
- success: true,
574
- oldVersion,
575
- newVersion,
576
- filePath: pkgPath
577
- })
578
- }
579
- } catch (error) {
580
- res.status(500).json({ success: false, error: error.message })
581
- }
582
- })
583
-
584
- // ========== NPM 脚本管理相关 API ==========
585
-
586
- // 存储正在进行的扫描任务
587
- let currentScanAbortController = null;
588
-
589
- // 扫描项目目录及子目录下的所有package.json,并提取scripts
590
- app.get('/api/scan-npm-scripts', async (req, res) => {
591
- // 取消之前的扫描
592
- if (currentScanAbortController) {
593
- currentScanAbortController.aborted = true;
594
- }
595
-
596
- // 创建新的abort controller
597
- currentScanAbortController = {
598
- aborted: false,
599
- abort() { this.aborted = true; }
600
- };
601
- const scanController = currentScanAbortController;
602
- try {
603
- const projectRoot = process.cwd();
604
- const packageJsons = [];
605
- const startTime = Date.now();
606
-
607
-
608
- // 需要忽略的目录列表(更全面)
609
- const IGNORED_DIRS = new Set([
610
- 'node_modules',
611
- '.git',
612
- '.svn',
613
- '.hg',
614
- 'dist',
615
- 'build',
616
- 'coverage',
617
- 'out',
618
- 'target',
619
- 'vendor',
620
- '__pycache__',
621
- '.next',
622
- '.nuxt',
623
- '.vscode',
624
- '.idea',
625
- 'tmp',
626
- 'temp',
627
- 'cache',
628
- '.cache'
629
- ]);
630
-
631
- // 优先扫描的子目录(monorepo常见结构)
632
- const PRIORITY_DIRS = ['packages', 'apps', 'libs', 'services', 'modules'];
633
-
634
- let scannedCount = 0;
635
- let skippedCount = 0;
636
- let fileReadCount = 0; // 统计实际读取的文件数量
637
-
638
- // 检查指定目录下是否有package.json
639
- async function checkPackageJson(dir) {
640
- if (scanController.aborted) return false;
641
-
642
- try {
643
- const packagePath = path.join(dir, 'package.json');
644
-
645
- // 先检查文件是否存在,避免不必要的读取
646
- try {
647
- await fs.access(packagePath);
648
- } catch {
649
- // 文件不存在,直接返回
650
- return false;
651
- }
652
-
653
- // 检查文件大小,避免读取异常大的文件
654
- const stats = await fs.stat(packagePath);
655
- const fileSizeMB = stats.size / (1024 * 1024);
656
- if (fileSizeMB > 1) {
657
- // package.json超过1MB是异常情况,跳过
658
- logger.info(`[NPM扫描] 跳过超大文件 (${fileSizeMB.toFixed(2)}MB): ${packagePath}`);
659
- return false;
660
- }
661
-
662
- fileReadCount++; // 只有文件存在且大小合理时才计数
663
- const content = await fs.readFile(packagePath, 'utf8');
664
- const packageData = JSON.parse(content);
665
-
666
- // 只有当scripts存在且至少有一个脚本时才添加
667
- if (packageData.scripts && Object.keys(packageData.scripts).length > 0) {
668
- const relativePath = path.relative(projectRoot, dir);
669
- packageJsons.push({
670
- path: dir,
671
- relativePath: relativePath || '.',
672
- name: packageData.name || path.basename(dir),
673
- scripts: packageData.scripts,
674
- version: packageData.version || '0.0.0',
675
- repository: packageData.repository
676
- });
677
- return true;
678
- }
679
- } catch (error) {
680
- // 文件不存在或解析失败,忽略
681
- }
682
- return false;
683
- }
684
-
685
- // 递归扫描目录,最大深度4层
686
- const MAX_DEPTH = 4;
687
- const MAX_DIRS_PER_LEVEL = 50; // 每层最多扫描50个子目录
688
-
689
- // 统计每层深度的扫描数量
690
- const depthStats = Array(MAX_DEPTH + 1).fill(0).map(() => ({ count: 0, time: 0 }));
691
-
692
- async function scanDirectory(dir, depth = 0) {
693
- if (scanController.aborted) return;
694
- if (depth > MAX_DEPTH) return;
695
-
696
- const depthStart = Date.now();
697
- scannedCount++;
698
- depthStats[depth].count++;
699
-
700
- // 检查当前目录的package.json
701
- await checkPackageJson(dir);
702
-
703
- // 如果已经达到最大深度,不再继续
704
- if (depth >= MAX_DEPTH) return;
705
-
706
- // 读取子目录
707
- try {
708
- if (scanController.aborted) return;
709
-
710
- const items = await fs.readdir(dir, { withFileTypes: true });
711
- const subDirs = [];
712
-
713
- // 收集所有子目录
714
- for (const item of items) {
715
- if (scanController.aborted) return;
716
- if (!item.isDirectory()) continue;
717
-
718
- const dirName = item.name;
719
-
720
- // 跳过忽略的目录
721
- if (IGNORED_DIRS.has(dirName) || dirName.startsWith('.')) {
722
- skippedCount++;
723
- continue;
724
- }
725
-
726
- subDirs.push(item);
727
- }
728
-
729
- // 限制每层扫描的子目录数量
730
- const dirsToScan = subDirs.slice(0, MAX_DIRS_PER_LEVEL);
731
- if (subDirs.length > MAX_DIRS_PER_LEVEL) {
732
- skippedCount += subDirs.length - MAX_DIRS_PER_LEVEL;
733
- }
734
-
735
- // 优先处理优先目录
736
- const priorityDirs = dirsToScan.filter(item => PRIORITY_DIRS.includes(item.name));
737
- const normalDirs = dirsToScan.filter(item => !PRIORITY_DIRS.includes(item.name));
738
-
739
- // 先扫描优先目录
740
- for (const item of priorityDirs) {
741
- if (scanController.aborted) return;
742
- const subDirPath = path.join(dir, item.name);
743
- await scanDirectory(subDirPath, depth + 1);
744
- }
745
-
746
- // 再扫描普通目录
747
- for (const item of normalDirs) {
748
- if (scanController.aborted) return;
749
- const subDirPath = path.join(dir, item.name);
750
- await scanDirectory(subDirPath, depth + 1);
751
- }
752
-
753
- } catch (error) {
754
- // 忽略无法访问的目录
755
- }
756
-
757
- // 记录该深度的耗时
758
- depthStats[depth].time += Date.now() - depthStart;
759
- }
760
-
761
- // 执行递归扫描
762
- const scanStart = Date.now();
763
- await scanDirectory(projectRoot, 0);
764
-
765
- // 扫描完成,清除abort controller
766
- if (currentScanAbortController === scanController) {
767
- currentScanAbortController = null;
768
- }
769
-
770
- const scanTime = Date.now() - startTime;
771
-
772
- if (scanController.aborted) {
773
- logger.info(`npm脚本扫描被取消,耗时${scanTime}ms`);
774
- return res.json({
775
- success: true,
776
- packages: [],
777
- totalScripts: 0,
778
- cancelled: true
779
- });
780
- }
781
-
782
- // 输出每层深度的统计
783
- const depthInfo = depthStats
784
- .map((stat, depth) => stat.count > 0 ? `深度${depth}:${stat.count}个(${stat.time}ms)` : null)
785
- .filter(Boolean)
786
- .join(', ');
787
-
788
-
789
- res.json({
790
- success: true,
791
- packages: packageJsons,
792
- totalScripts: packageJsons.reduce((sum, pkg) => sum + Object.keys(pkg.scripts).length, 0)
793
- });
794
- } catch (error) {
795
- logger.error('扫描npm脚本失败:', error);
796
- res.status(500).json({
797
- success: false,
798
- error: `扫描npm脚本失败: ${error.message}`
799
- });
800
- }
801
- });
802
-
803
- // 扫描项目目录下的所有package.json文件(用于版本管理)
804
- app.get('/api/scan-package-files', async (req, res) => {
805
- try {
806
- // 支持通过查询参数指定扫描目录,如果没有指定则使用当前工作目录
807
- const customDirectory = req.query.directory;
808
- const projectRoot = customDirectory || process.cwd();
809
-
810
- // 验证目录是否存在且可访问
811
- try {
812
- const stats = await fs.stat(projectRoot);
813
- if (!stats.isDirectory()) {
814
- return res.status(400).json({
815
- success: false,
816
- error: '指定的路径不是一个有效的目录'
817
- });
818
- }
819
- } catch (error) {
820
- return res.status(400).json({
821
- success: false,
822
- error: `无法访问指定的目录: ${error.message}`
823
- });
824
- }
825
-
826
- const packageFiles = [];
827
- const startTime = Date.now();
828
-
829
- // 需要忽略的目录列表
830
- const IGNORED_DIRS = new Set([
831
- 'node_modules',
832
- '.git',
833
- '.svn',
834
- '.hg',
835
- 'dist',
836
- 'build',
837
- 'coverage',
838
- 'out',
839
- 'target',
840
- 'vendor',
841
- '__pycache__',
842
- '.next',
843
- '.nuxt',
844
- '.vscode',
845
- '.idea',
846
- 'tmp',
847
- 'temp',
848
- 'cache',
849
- '.cache'
850
- ]);
851
-
852
- let scannedCount = 0;
853
- let fileReadCount = 0;
854
-
855
- // 检查指定目录下是否有package.json
856
- async function checkPackageJson(dir) {
857
- try {
858
- const packagePath = path.join(dir, 'package.json');
859
-
860
- // 先检查文件是否存在
861
- try {
862
- await fs.access(packagePath);
863
- } catch {
864
- return false;
865
- }
866
-
867
- // 检查文件大小
868
- const stats = await fs.stat(packagePath);
869
- const fileSizeMB = stats.size / (1024 * 1024);
870
- if (fileSizeMB > 1) {
871
- return false;
872
- }
873
-
874
- fileReadCount++;
875
- const content = await fs.readFile(packagePath, 'utf8');
876
- const packageData = JSON.parse(content);
877
-
878
- // 添加所有有效的package.json文件(不仅仅是有scripts的)
879
- if (packageData.name || packageData.version) {
880
- const relativePath = path.relative(projectRoot, dir);
881
- packageFiles.push({
882
- path: dir,
883
- relativePath: relativePath || '.',
884
- name: packageData.name || path.basename(dir),
885
- version: packageData.version || '0.0.0',
886
- displayName: packageData.name ? `${packageData.name} (${packageData.version || '0.0.0'})` : `${path.basename(dir)} (${packageData.version || '0.0.0'})`,
887
- fullPath: packagePath
888
- });
889
- return true;
890
- }
891
- } catch (error) {
892
- // 文件不存在或解析失败,忽略
893
- }
894
- return false;
895
- }
896
-
897
- // 递归扫描目录,最大深度4层
898
- const MAX_DEPTH = 4;
899
- const MAX_DIRS_PER_LEVEL = 50;
900
-
901
- async function scanDirectory(dir, depth = 0) {
902
- if (depth > MAX_DEPTH) return;
903
-
904
- scannedCount++;
905
-
906
- // 检查当前目录的package.json
907
- await checkPackageJson(dir);
908
-
909
- // 如果已经达到最大深度,不再继续
910
- if (depth >= MAX_DEPTH) return;
911
-
912
- // 读取子目录
913
- try {
914
- const items = await fs.readdir(dir, { withFileTypes: true });
915
- const subDirs = [];
916
-
917
- // 收集所有子目录
918
- for (const item of items) {
919
- if (!item.isDirectory()) continue;
920
-
921
- const dirName = item.name;
922
-
923
- // 跳过忽略的目录
924
- if (IGNORED_DIRS.has(dirName) || dirName.startsWith('.')) {
925
- continue;
926
- }
927
-
928
- subDirs.push(item);
929
- }
930
-
931
- // 限制每层扫描的子目录数量
932
- const dirsToScan = subDirs.slice(0, MAX_DIRS_PER_LEVEL);
933
-
934
- // 递归扫描子目录
935
- for (const item of dirsToScan) {
936
- const subDirPath = path.join(dir, item.name);
937
- await scanDirectory(subDirPath, depth + 1);
938
- }
939
- } catch (error) {
940
- // 忽略无法读取的目录
941
- }
942
- }
943
-
944
- // 开始扫描
945
- await scanDirectory(projectRoot);
946
-
947
- const scanTime = Date.now() - startTime;
948
-
949
- res.json({
950
- success: true,
951
- packages: packageFiles,
952
- scanTime,
953
- scannedCount,
954
- fileReadCount
955
- });
956
- } catch (error) {
957
- logger.error('扫描package.json文件失败:', error);
958
- res.status(500).json({
959
- success: false,
960
- error: `扫描package.json文件失败: ${error.message}`
961
- });
962
- }
963
- });
964
-
965
- // 在新终端中执行npm脚本
966
- app.post('/api/run-npm-script', async (req, res) => {
967
- try {
968
- const { packagePath, scriptName } = req.body;
969
-
970
- if (!packagePath || !scriptName) {
971
- return res.status(400).json({
972
- success: false,
973
- error: '缺少必要参数:packagePath 和 scriptName'
974
- });
975
- }
976
-
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ import express from 'express';
16
+ import logger from '../utils/logger.js'
17
+ import fs from 'fs/promises';
18
+ import fsSync from 'fs';
19
+ import path from 'path';
20
+ import os from 'os';
21
+ import { exec, spawn } from 'child_process';
22
+ import https from 'https';
23
+ import { fileURLToPath } from 'url';
24
+ import { getRegistryPath } from '../utils/instanceRegistry.js';
25
+
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = path.dirname(__filename);
28
+
29
+ export function registerNpmRoutes({
30
+ app,
31
+ getCurrentProjectPath
32
+ }) {
33
+ // 标记是否有升级任务在跑(防并发)
34
+ let activeUpgrade = false;
35
+ // 标记是否有重启任务在跑(防并发)
36
+ let activeRestart = false;
37
+ // 自拉起重启相关常量
38
+ const RESTART_TIMEOUT_MS = 15000; // 子进程必须在此时间内注册到 instanceRegistry(Windows 冷启动偏慢)
39
+ const REGISTRY_POLL_MS = 250; // 轮询注册表文件间隔
40
+ const EXIT_DELAY_MS = 100; // 子进程就绪后给前端的最后缓冲时间
41
+
42
+ // ========== 应用自升级相关 API ==========
43
+
44
+ // 解析"自拉起"要 spawn 的入口绝对路径。
45
+ // 优先 process.argv[1](开发态 server.js / 生产态 src/gitCommit.js 都是绝对路径),
46
+ // 但 launch.json 的 backend 是 `node -e "...process.argv = [...]; require('./server.js')"`,
47
+ // 这种篡改出来的 argv[1] 是相对路径 "server.js",不能直接 spawn。
48
+ // 回退: 用 __dirname 推导 src/ui/server/index.js 的同级 server.js(仓库根)
49
+ function resolveRestartEntry() {
50
+ const arg = process.argv[1]
51
+ if (arg && path.isAbsolute(arg) && fsSync.existsSync(arg)) {
52
+ return { entry: arg, args: process.argv.slice(2) }
53
+ }
54
+ // 回退到仓库根的 server.js(开发态入口),保留原 PORT 等环境变量
55
+ const fallback = path.resolve(__dirname, '../../../..', 'server.js')
56
+ if (fsSync.existsSync(fallback)) {
57
+ // 透传用户原始 argv[2..](--no-open 等);如果原 argv[1] 是 "server.js" 这类相对路径,
58
+ // 用户大概率没传任何 args,所以空数组也合理
59
+ const originalArgs = (arg && !path.isAbsolute(arg)) ? process.argv.slice(2) : []
60
+ return { entry: fallback, args: originalArgs }
61
+ }
62
+ return null
63
+ }
64
+
65
+ // 当前安装的版本(从外层 package.json 读取)
66
+ function getCurrentVersion() {
67
+ try {
68
+ // src/ui/server/routes/npm.js -> ../../../../package.json
69
+ const pkgPath = path.resolve(__dirname, '../../../..', 'package.json')
70
+ if (fsSync.existsSync(pkgPath)) {
71
+ const pkg = JSON.parse(fsSync.readFileSync(pkgPath, 'utf8'))
72
+ return pkg.version || '0.0.0'
73
+ }
74
+ } catch (err) {
75
+ logger.error('读取本地 package.json 版本失败:', err)
76
+ }
77
+ return '0.0.0'
78
+ }
79
+
80
+ // 简单的 semver 比较:a > b 返回 1, a < b 返回 -1, 相等返回 0
81
+ function compareSemver(a, b) {
82
+ const pa = a.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
83
+ const pb = b.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
84
+ for (let i = 0; i < 3; i++) {
85
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1
86
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1
87
+ }
88
+ return 0
89
+ }
90
+
91
+ // 缓存 npm registry latest 结果 10 分钟
92
+ let latestVersionCache = { value: null, at: 0 }
93
+ const LATEST_TTL_MS = 10 * 60 * 1000
94
+
95
+ function fetchLatestFromRegistry() {
96
+ return new Promise((resolve, reject) => {
97
+ const req = https.get(
98
+ 'https://registry.npmjs.org/zen-gitsync/latest',
99
+ { timeout: 5000, headers: { 'User-Agent': 'zen-gitsync-app' } },
100
+ (res) => {
101
+ let data = ''
102
+ res.on('data', chunk => { data += chunk })
103
+ res.on('end', () => {
104
+ if (res.statusCode !== 200) {
105
+ return reject(new Error(`registry 返回状态 ${res.statusCode}`))
106
+ }
107
+ try {
108
+ const pkg = JSON.parse(data)
109
+ resolve(pkg.version || null)
110
+ } catch (e) {
111
+ reject(new Error('解析 registry 响应失败'))
112
+ }
113
+ })
114
+ }
115
+ )
116
+ req.on('timeout', () => {
117
+ req.destroy(new Error('registry 请求超时'))
118
+ })
119
+ req.on('error', err => reject(err))
120
+ })
121
+ }
122
+
123
+ async function getLatestVersion() {
124
+ const now = Date.now()
125
+ if (latestVersionCache.value && now - latestVersionCache.at < LATEST_TTL_MS) {
126
+ return latestVersionCache.value
127
+ }
128
+ const v = await fetchLatestFromRegistry()
129
+ latestVersionCache = { value: v, at: now }
130
+ return v
131
+ }
132
+
133
+ // GET /api/app-version
134
+ app.get('/api/app-version', async (_req, res) => {
135
+ const current = getCurrentVersion()
136
+ try {
137
+ const latest = await getLatestVersion()
138
+ const hasUpdate = !!latest && compareSemver(latest, current) > 0
139
+ res.json({ success: true, current, latest, hasUpdate })
140
+ } catch (err) {
141
+ // 拉取失败时仍返回当前版本,前端据此决定是否显示升级按钮
142
+ res.json({
143
+ success: false,
144
+ current,
145
+ latest: null,
146
+ hasUpdate: false,
147
+ error: err.message
148
+ })
149
+ }
150
+ })
151
+
152
+ // POST /api/app-upgrade - 流式 NDJSON: { type: 'stdout'|'stderr'|'done'|'error', ... }
153
+ app.post('/api/app-upgrade', (req, res) => {
154
+ // 防并发:同一时刻只允许一个升级任务
155
+ if (activeUpgrade) {
156
+ res.status(409).json({ success: false, error: '已有升级任务在进行中' })
157
+ return
158
+ }
159
+
160
+ res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8')
161
+ res.setHeader('Cache-Control', 'no-cache')
162
+ res.setHeader('Connection', 'keep-alive')
163
+ res.flushHeaders?.()
164
+
165
+ const send = (obj) => {
166
+ res.write(JSON.stringify(obj) + '\n')
167
+ }
168
+
169
+ // Windows 上 npm install -g 通常不需要管理员;*nix 需要 sudo。
170
+ // 用 sudo -n(非交互式)检测是否可免密,失败时让用户用 sudo 重启 GUI
171
+ const isWin = process.platform === 'win32'
172
+ const npmCmd = isWin ? 'npm.cmd' : 'npm'
173
+ const args = ['install', '-g', 'zen-gitsync', '--registry', 'https://registry.npmjs.org/']
174
+ const cmd = isWin ? npmCmd : 'sudo'
175
+ const finalArgs = isWin ? args : ['-n', npmCmd, ...args]
176
+
177
+ activeUpgrade = send
178
+ const cleanup = () => { activeUpgrade = null }
179
+
180
+ let child
181
+ try {
182
+ child = spawn(cmd, finalArgs, {
183
+ env: { ...process.env, FORCE_COLOR: '0' },
184
+ windowsHide: true,
185
+ // Windows 下调用 .cmd 必须 shell:true
186
+ shell: isWin
187
+ })
188
+ } catch (err) {
189
+ send({ type: 'error', message: `启动升级进程失败: ${err.message}` })
190
+ send({ type: 'done', code: -1 })
191
+ res.end()
192
+ cleanup()
193
+ return
194
+ }
195
+
196
+ send({ type: 'stdout', message: `$ ${cmd} ${finalArgs.join(' ')}\n` })
197
+
198
+ child.stdout.on('data', (buf) => send({ type: 'stdout', message: buf.toString() }))
199
+ child.stderr.on('data', (buf) => send({ type: 'stderr', message: buf.toString() }))
200
+
201
+ child.on('error', (err) => {
202
+ send({ type: 'error', message: `升级进程错误: ${err.message}` })
203
+ })
204
+
205
+ child.on('close', (code) => {
206
+ if (code === 0) {
207
+ send({ type: 'stdout', message: '\n✅ 升级完成,新版本已全局安装。\n' })
208
+ // 清理缓存,强制下次重新拉 latest
209
+ latestVersionCache = { value: null, at: 0 }
210
+ } else {
211
+ let hint = ''
212
+ if (!isWin) {
213
+ hint = '(提示:在 macOS/Linux 上全局安装需要 sudo,请用管理员权限重启 GUI 后再试)'
214
+ }
215
+ send({ type: 'error', message: `\n升级失败,退出码 ${code}${hint ? ' ' + hint : ''}\n` })
216
+ }
217
+ send({ type: 'done', code })
218
+ res.end()
219
+ cleanup()
220
+ })
221
+ })
222
+
223
+
224
+ // 同步读取 instanceRegistry 文件并返回 Map<pid, entry>
225
+ // npm.js 在自拉起重启场景下需要轮询此文件判断子进程是否已绑定端口
226
+ function readRegistrySync(p) {
227
+ const m = new Map();
228
+ try {
229
+ const raw = fsSync.readFileSync(p, 'utf-8');
230
+ const obj = JSON.parse(raw);
231
+ const instances = (obj && obj.instances) || {};
232
+ for (const [pidStr, entry] of Object.entries(instances)) {
233
+ const pid = Number(pidStr);
234
+ if (Number.isFinite(pid) && entry && typeof entry === 'object') {
235
+ m.set(pid, entry);
236
+ }
237
+ }
238
+ } catch (_) {
239
+ // 文件不存在或 JSON 损坏 → 返回空 Map,调用方按"还没注册"处理
240
+ }
241
+ return m;
242
+ }
243
+
244
+ // POST /api/app-restart - 自拉起重启:spawn 当前进程入口 + 轮询 instanceRegistry
245
+ // 拿到子进程端口后通过 NDJSON 流告知前端;旧进程再退出
246
+ app.post('/api/app-restart', (_req, res) => {
247
+ if (activeRestart) {
248
+ res.status(409).json({ success: false, error: '已有重启任务在进行中' });
249
+ return;
250
+ }
251
+ activeRestart = true;
252
+
253
+ res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
254
+ res.setHeader('Cache-Control', 'no-cache');
255
+ res.setHeader('Connection', 'keep-alive');
256
+ res.flushHeaders?.();
257
+
258
+ const send = (obj) => { try { res.write(JSON.stringify(obj) + '\n') } catch (_) {} };
259
+ const finish = () => { try { res.end() } catch (_) {} };
260
+ const release = () => { activeRestart = false };
261
+
262
+ // 1. 记录当前所有 instance,后续用"出现的新 pid"判定子进程
263
+ const registryPath = getRegistryPath();
264
+ const before = readRegistrySync(registryPath);
265
+ const start = Date.now();
266
+ const spawnAt = Date.now();
267
+
268
+ // 2. 准备子进程 ready 信号文件 —— startServerOnAvailablePort 在 listen 成功后会写入
269
+ // (instanceRegistry 在 EPERM 环境下不可用,这是回退通道)
270
+ const notifyPath = path.join(
271
+ os.tmpdir(),
272
+ `zen-gitsync-restart-${process.pid}-${Date.now()}.port`
273
+ );
274
+ try { fsSync.unlinkSync(notifyPath) } catch (_) {} // 清掉旧同名文件
275
+
276
+ const entryInfo = resolveRestartEntry();
277
+ if (!entryInfo) {
278
+ send({ type: 'error', message: '无法解析重启入口(process.argv[1] 既不是绝对路径,仓库根也没有 server.js)' });
279
+ finish();
280
+ release();
281
+ return;
282
+ }
283
+
284
+ // 子进程 stdout/stderr 写到临时日志文件,便于诊断"cmd 闪退"
285
+ const childLogFile = path.join(
286
+ os.tmpdir(),
287
+ `zen-gitsync-restart-${process.pid}-${Date.now()}.log`
288
+ );
289
+ let childLogFd;
290
+ try {
291
+ childLogFd = fsSync.openSync(childLogFile, 'a');
292
+ } catch (err) {
293
+ send({ type: 'error', message: `打开子进程日志文件失败: ${err?.message || err}` });
294
+ finish();
295
+ release();
296
+ return;
297
+ }
298
+
299
+ let child;
300
+ try {
301
+ // spawn 当前进程当时是怎么被 node 启的同一个入口(开发态 server.js / 生产态 gitCommit.js)
302
+ // detached: true + stdio 指向日志文件 + child.unref() → Windows 上父进程 exit 不会带走子进程
303
+ child = spawn(process.execPath, [entryInfo.entry, ...entryInfo.args], {
304
+ detached: true,
305
+ stdio: ['ignore', childLogFd, childLogFd],
306
+ windowsHide: true,
307
+ env: { ...process.env, ZEN_RESTART_NOTIFY_PATH: notifyPath }
308
+ });
309
+ child.unref();
310
+ } catch (err) {
311
+ send({ type: 'error', message: `拉起子进程失败: ${err?.message || err}` });
312
+ try { fsSync.closeSync(childLogFd) } catch (_) {}
313
+ try { fsSync.unlinkSync(notifyPath) } catch (_) {}
314
+ try { fsSync.unlinkSync(childLogFile) } catch (_) {}
315
+ finish();
316
+ release();
317
+ return;
318
+ }
319
+
320
+ send({ type: 'stdout', message: `[restart] 已在 PID ${child.pid} 拉起子进程 (${entryInfo.entry}),等待端口就绪…\n` });
321
+ send({ type: 'stdout', message: `[restart] 子进程日志: ${childLogFile}\n` });
322
+
323
+ // 监听子进程异常退出,避免"cmd 闪一下没了却没人知道为什么"
324
+ let childDead = false;
325
+ child.on('error', (err) => {
326
+ if (childDead) return;
327
+ childDead = true;
328
+ send({ type: 'error', message: `子进程 spawn 失败: ${err?.message || err}` });
329
+ try { fsSync.closeSync(childLogFd) } catch (_) {}
330
+ try { fsSync.unlinkSync(notifyPath) } catch (_) {}
331
+ finish();
332
+ release();
333
+ });
334
+ child.on('exit', (code, signal) => {
335
+ if (childDead) return;
336
+ childDead = true;
337
+ // 读子进程日志尾部,把崩溃原因推给前端
338
+ let tail = '';
339
+ try {
340
+ const buf = fsSync.readFileSync(childLogFile, 'utf8');
341
+ tail = buf.slice(-2000);
342
+ } catch (_) {}
343
+ send({
344
+ type: 'error',
345
+ message: `子进程提前退出 (code=${code} signal=${signal})${tail ? `\n--- 子进程日志尾部 ---\n${tail}` : ''}`
346
+ });
347
+ try { fsSync.closeSync(childLogFd) } catch (_) {}
348
+ try { fsSync.unlinkSync(notifyPath) } catch (_) {}
349
+ finish();
350
+ release();
351
+ });
352
+
353
+ const poll = setInterval(() => {
354
+ if (Date.now() - start > RESTART_TIMEOUT_MS) {
355
+ clearInterval(poll);
356
+ childDead = true; // 阻止 exit 监听器再发一次 error,避免和 timeout 重复
357
+ try { child.kill('SIGTERM') } catch (_) {}
358
+ // 读子进程日志尾部,帮助诊断为啥超时
359
+ let tail = '';
360
+ try { tail = fsSync.readFileSync(childLogFile, 'utf8').slice(-2000) } catch (_) {}
361
+ send({
362
+ type: 'error',
363
+ message: `子进程 ${RESTART_TIMEOUT_MS / 1000}s 内未就绪(未读到 ${notifyPath}),已 kill${tail ? `\n--- 子进程日志尾部 ---\n${tail}` : ''}`
364
+ });
365
+ send({ type: 'timeout' });
366
+ finish();
367
+ try { fsSync.closeSync(childLogFd) } catch (_) {}
368
+ try { fsSync.unlinkSync(notifyPath) } catch (_) {}
369
+ try { fsSync.unlinkSync(childLogFile) } catch (_) {}
370
+ // 关键:超时分支旧进程不退出,用户仍可继续手动刷新页面
371
+ release();
372
+ return;
373
+ }
374
+
375
+ // 优先判定:子进程是否写了 notify 文件(最可靠,不走 instanceRegistry)
376
+ let notifiedPort = null;
377
+ try {
378
+ const raw = fsSync.readFileSync(notifyPath, 'utf8').trim();
379
+ const p = Number(raw);
380
+ if (Number.isInteger(p) && p > 0 && p < 65536) notifiedPort = p;
381
+ } catch (_) {}
382
+
383
+ // 兜底:instanceRegistry 里出现的新 pid + port(用于没有 savePort 的环境)
384
+ let registryPort = null;
385
+ if (!notifiedPort) {
386
+ const after = readRegistrySync(registryPath);
387
+ for (const [pid, entry] of after) {
388
+ if (
389
+ !before.has(pid) &&
390
+ Number.isInteger(entry.port) && entry.port > 0 &&
391
+ (entry.lastHeartbeat ?? entry.startedAt ?? 0) >= spawnAt
392
+ ) {
393
+ registryPort = entry.port;
394
+ break;
395
+ }
396
+ }
397
+ }
398
+
399
+ const newPort = notifiedPort ?? registryPort;
400
+ if (newPort) {
401
+ clearInterval(poll);
402
+ childDead = true; // 子进程已就绪,阻止 exit 监听器误报 error
403
+ // 给 EXIT_DELAY_MS 让新进程稳定,再通知前端 + 旧进程退出
404
+ setTimeout(() => {
405
+ send({ type: 'ready', port: newPort });
406
+ finish();
407
+ try { fsSync.unlinkSync(notifyPath) } catch (_) {}
408
+ try { fsSync.closeSync(childLogFd) } catch (_) {}
409
+ // 成功分支不删 childLogFile:子进程仍可能往里写新日志,便于下次诊断;交给 OS 临时文件清理
410
+ logger.info('[app-restart] 子进程已就绪,退出当前进程');
411
+ process.exit(0);
412
+ }, EXIT_DELAY_MS);
413
+ return;
414
+ }
415
+ }, REGISTRY_POLL_MS);
416
+ });
417
+
418
+
419
+ // 读取 package.json 文件内容
420
+ app.post('/api/read-package-json', express.json(), async (req, res) => {
421
+ try {
422
+ const { packageJsonPath } = req.body
423
+
424
+ // 确定 package.json 的路径
425
+ let pkgPath
426
+ if (packageJsonPath && packageJsonPath.trim()) {
427
+ pkgPath = path.isAbsolute(packageJsonPath)
428
+ ? packageJsonPath
429
+ : path.join(getCurrentProjectPath(), packageJsonPath)
430
+ } else {
431
+ pkgPath = path.join(getCurrentProjectPath(), 'package.json')
432
+ }
433
+
434
+ // 检查文件是否存在
435
+ try {
436
+ const stats = await fs.stat(pkgPath)
437
+ if (stats.isDirectory()) {
438
+ pkgPath = path.join(pkgPath, 'package.json')
439
+ }
440
+ await fs.access(pkgPath)
441
+ } catch (err) {
442
+ return res.status(404).json({
443
+ success: false,
444
+ error: `未找到 package.json 文件: ${pkgPath}`
445
+ })
446
+ }
447
+
448
+ // 读取 package.json
449
+ const pkgContent = await fs.readFile(pkgPath, 'utf8')
450
+ const pkg = JSON.parse(pkgContent)
451
+
452
+ res.json({
453
+ success: true,
454
+ dependencies: pkg.dependencies || {},
455
+ devDependencies: pkg.devDependencies || {},
456
+ peerDependencies: pkg.peerDependencies || {},
457
+ peerDependenciesMeta: pkg.peerDependenciesMeta || {},
458
+ version: pkg.version
459
+ })
460
+ } catch (error) {
461
+ res.status(500).json({ success: false, error: error.message })
462
+ }
463
+ })
464
+
465
+ // 版本号递增或依赖版本修改
466
+ app.post('/api/version-bump', express.json(), async (req, res) => {
467
+ try {
468
+ const { bumpType, packageJsonPath, versionTarget, dependencyName, dependencyVersion, dependencyVersionBump, dependencyType, versionValue } = req.body
469
+
470
+ // 确定 package.json 的路径
471
+ let pkgPath
472
+ if (packageJsonPath && packageJsonPath.trim()) {
473
+ pkgPath = path.isAbsolute(packageJsonPath)
474
+ ? packageJsonPath
475
+ : path.join(getCurrentProjectPath(), packageJsonPath)
476
+ } else {
477
+ pkgPath = path.join(getCurrentProjectPath(), 'package.json')
478
+ }
479
+
480
+ // 检查文件是否存在(使用 Node.js 原生方法)
481
+ try {
482
+ await fs.access(pkgPath)
483
+ } catch (err) {
484
+ return res.status(404).json({
485
+ success: false,
486
+ error: `未找到 package.json 文件: ${pkgPath}`
487
+ })
488
+ }
489
+
490
+ // 读取 package.json
491
+ const pkgContent = await fs.readFile(pkgPath, 'utf8')
492
+ const pkg = JSON.parse(pkgContent)
493
+
494
+ // 判断是修改 version 还是 dependency
495
+ if (versionTarget === 'dependency') {
496
+ // 修改依赖版本
497
+ const depType = dependencyType || 'dependencies'
498
+
499
+ if (!pkg[depType]) {
500
+ return res.status(400).json({
501
+ success: false,
502
+ error: `package.json 中未找到 ${depType} 字段`
503
+ })
504
+ }
505
+
506
+ if (!pkg[depType][dependencyName]) {
507
+ return res.status(400).json({
508
+ success: false,
509
+ error: `在 ${depType} 中未找到依赖包: ${dependencyName}`
510
+ })
511
+ }
512
+
513
+ const oldVersion = pkg[depType][dependencyName]
514
+ let newVersion
515
+
516
+ // 判断是自动递增还是手动输入
517
+ if (dependencyVersionBump) {
518
+ // 自动递增模式:解析当前版本号并递增
519
+ // 提取版本号中的数字部分(去除 ^, ~, >=, 等前缀)
520
+ const versionMatch = oldVersion.match(/(\^|~|>=|>|<=|<)?(\d+\.\d+\.\d+)/)
521
+ if (!versionMatch) {
522
+ return res.status(400).json({
523
+ success: false,
524
+ error: `无法解析版本号: ${oldVersion},应为 x.y.z 格式(可带 ^, ~ 等前缀)`
525
+ })
526
+ }
527
+
528
+ const prefix = versionMatch[1] || ''
529
+ const versionNumber = versionMatch[2]
530
+ const versionParts = versionNumber.split('.').map(Number)
531
+
532
+ if (versionParts.length !== 3 || versionParts.some(isNaN)) {
533
+ return res.status(400).json({
534
+ success: false,
535
+ error: `无效的版本号格式: ${versionNumber}`
536
+ })
537
+ }
538
+
539
+ // 根据类型递增版本号
540
+ let [major, minor, patch] = versionParts
541
+ if (dependencyVersionBump === 'major') {
542
+ major += 1
543
+ minor = 0
544
+ patch = 0
545
+ } else if (dependencyVersionBump === 'minor') {
546
+ minor += 1
547
+ patch = 0
548
+ } else { // patch
549
+ patch += 1
550
+ }
551
+
552
+ newVersion = `${prefix}${major}.${minor}.${patch}`
553
+ } else {
554
+ // 手动输入模式
555
+ newVersion = dependencyVersion
556
+ }
557
+
558
+ pkg[depType][dependencyName] = newVersion
559
+
560
+ // 写回 package.json(保持格式化)
561
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
562
+
563
+ res.json({
564
+ success: true,
565
+ oldVersion,
566
+ newVersion,
567
+ filePath: pkgPath,
568
+ dependencyName,
569
+ dependencyType: depType
570
+ })
571
+ } else {
572
+ // 修改 version 字段
573
+ if (!pkg.version) {
574
+ return res.status(400).json({
575
+ success: false,
576
+ error: 'package.json 中未找到 version 字段'
577
+ })
578
+ }
579
+
580
+ const oldVersion = pkg.version
581
+
582
+ // 直接设置版本号(来自版本节点输入配置)
583
+ const direct = typeof versionValue === 'string' ? versionValue.trim() : ''
584
+ if (direct) {
585
+ const versionParts = direct.split('.').map(Number)
586
+ if (versionParts.length !== 3 || versionParts.some(isNaN)) {
587
+ return res.status(400).json({
588
+ success: false,
589
+ error: `无效的版本号格式: ${direct},应为 x.y.z 格式`
590
+ })
591
+ }
592
+
593
+ pkg.version = direct
594
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
595
+
596
+ return res.json({
597
+ success: true,
598
+ oldVersion,
599
+ newVersion: direct,
600
+ filePath: pkgPath
601
+ })
602
+ }
603
+
604
+ // 解析版本号
605
+ const versionParts = oldVersion.split('.').map(Number)
606
+ if (versionParts.length !== 3 || versionParts.some(isNaN)) {
607
+ return res.status(400).json({
608
+ success: false,
609
+ error: `无效的版本号格式: ${oldVersion},应为 x.y.z 格式`
610
+ })
611
+ }
612
+
613
+ // 根据类型递增版本号
614
+ let [major, minor, patch] = versionParts
615
+ if (bumpType === 'major') {
616
+ major += 1
617
+ minor = 0
618
+ patch = 0
619
+ } else if (bumpType === 'minor') {
620
+ minor += 1
621
+ patch = 0
622
+ } else { // patch
623
+ patch += 1
624
+ }
625
+
626
+ const newVersion = `${major}.${minor}.${patch}`
627
+ pkg.version = newVersion
628
+
629
+ // 写回 package.json(保持格式化)
630
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
631
+
632
+ res.json({
633
+ success: true,
634
+ oldVersion,
635
+ newVersion,
636
+ filePath: pkgPath
637
+ })
638
+ }
639
+ } catch (error) {
640
+ res.status(500).json({ success: false, error: error.message })
641
+ }
642
+ })
643
+
644
+ // ========== NPM 脚本管理相关 API ==========
645
+
646
+ // 存储正在进行的扫描任务
647
+ let currentScanAbortController = null;
648
+
649
+ // 扫描项目目录及子目录下的所有package.json,并提取scripts
650
+ app.get('/api/scan-npm-scripts', async (req, res) => {
651
+ // 取消之前的扫描
652
+ if (currentScanAbortController) {
653
+ currentScanAbortController.aborted = true;
654
+ }
655
+
656
+ // 创建新的abort controller
657
+ currentScanAbortController = {
658
+ aborted: false,
659
+ abort() { this.aborted = true; }
660
+ };
661
+ const scanController = currentScanAbortController;
662
+ try {
663
+ const projectRoot = process.cwd();
664
+ const packageJsons = [];
665
+ const startTime = Date.now();
666
+
667
+
668
+ // 需要忽略的目录列表(更全面)
669
+ const IGNORED_DIRS = new Set([
670
+ 'node_modules',
671
+ '.git',
672
+ '.svn',
673
+ '.hg',
674
+ 'dist',
675
+ 'build',
676
+ 'coverage',
677
+ 'out',
678
+ 'target',
679
+ 'vendor',
680
+ '__pycache__',
681
+ '.next',
682
+ '.nuxt',
683
+ '.vscode',
684
+ '.idea',
685
+ 'tmp',
686
+ 'temp',
687
+ 'cache',
688
+ '.cache'
689
+ ]);
690
+
691
+ // 优先扫描的子目录(monorepo常见结构)
692
+ const PRIORITY_DIRS = ['packages', 'apps', 'libs', 'services', 'modules'];
693
+
694
+ let scannedCount = 0;
695
+ let skippedCount = 0;
696
+ let fileReadCount = 0; // 统计实际读取的文件数量
697
+
698
+ // 检查指定目录下是否有package.json
699
+ async function checkPackageJson(dir) {
700
+ if (scanController.aborted) return false;
701
+
702
+ try {
703
+ const packagePath = path.join(dir, 'package.json');
704
+
705
+ // 先检查文件是否存在,避免不必要的读取
706
+ try {
707
+ await fs.access(packagePath);
708
+ } catch {
709
+ // 文件不存在,直接返回
710
+ return false;
711
+ }
712
+
713
+ // 检查文件大小,避免读取异常大的文件
714
+ const stats = await fs.stat(packagePath);
715
+ const fileSizeMB = stats.size / (1024 * 1024);
716
+ if (fileSizeMB > 1) {
717
+ // package.json超过1MB是异常情况,跳过
718
+ logger.info(`[NPM扫描] 跳过超大文件 (${fileSizeMB.toFixed(2)}MB): ${packagePath}`);
719
+ return false;
720
+ }
721
+
722
+ fileReadCount++; // 只有文件存在且大小合理时才计数
723
+ const content = await fs.readFile(packagePath, 'utf8');
724
+ const packageData = JSON.parse(content);
725
+
726
+ // 只有当scripts存在且至少有一个脚本时才添加
727
+ if (packageData.scripts && Object.keys(packageData.scripts).length > 0) {
728
+ const relativePath = path.relative(projectRoot, dir);
729
+ packageJsons.push({
730
+ path: dir,
731
+ relativePath: relativePath || '.',
732
+ name: packageData.name || path.basename(dir),
733
+ scripts: packageData.scripts,
734
+ version: packageData.version || '0.0.0',
735
+ repository: packageData.repository
736
+ });
737
+ return true;
738
+ }
739
+ } catch (error) {
740
+ // 文件不存在或解析失败,忽略
741
+ }
742
+ return false;
743
+ }
744
+
745
+ // 递归扫描目录,最大深度4层
746
+ const MAX_DEPTH = 4;
747
+ const MAX_DIRS_PER_LEVEL = 50; // 每层最多扫描50个子目录
748
+
749
+ // 统计每层深度的扫描数量
750
+ const depthStats = Array(MAX_DEPTH + 1).fill(0).map(() => ({ count: 0, time: 0 }));
751
+
752
+ async function scanDirectory(dir, depth = 0) {
753
+ if (scanController.aborted) return;
754
+ if (depth > MAX_DEPTH) return;
755
+
756
+ const depthStart = Date.now();
757
+ scannedCount++;
758
+ depthStats[depth].count++;
759
+
760
+ // 检查当前目录的package.json
761
+ await checkPackageJson(dir);
762
+
763
+ // 如果已经达到最大深度,不再继续
764
+ if (depth >= MAX_DEPTH) return;
765
+
766
+ // 读取子目录
767
+ try {
768
+ if (scanController.aborted) return;
769
+
770
+ const items = await fs.readdir(dir, { withFileTypes: true });
771
+ const subDirs = [];
772
+
773
+ // 收集所有子目录
774
+ for (const item of items) {
775
+ if (scanController.aborted) return;
776
+ if (!item.isDirectory()) continue;
777
+
778
+ const dirName = item.name;
779
+
780
+ // 跳过忽略的目录
781
+ if (IGNORED_DIRS.has(dirName) || dirName.startsWith('.')) {
782
+ skippedCount++;
783
+ continue;
784
+ }
785
+
786
+ subDirs.push(item);
787
+ }
788
+
789
+ // 限制每层扫描的子目录数量
790
+ const dirsToScan = subDirs.slice(0, MAX_DIRS_PER_LEVEL);
791
+ if (subDirs.length > MAX_DIRS_PER_LEVEL) {
792
+ skippedCount += subDirs.length - MAX_DIRS_PER_LEVEL;
793
+ }
794
+
795
+ // 优先处理优先目录
796
+ const priorityDirs = dirsToScan.filter(item => PRIORITY_DIRS.includes(item.name));
797
+ const normalDirs = dirsToScan.filter(item => !PRIORITY_DIRS.includes(item.name));
798
+
799
+ // 先扫描优先目录
800
+ for (const item of priorityDirs) {
801
+ if (scanController.aborted) return;
802
+ const subDirPath = path.join(dir, item.name);
803
+ await scanDirectory(subDirPath, depth + 1);
804
+ }
805
+
806
+ // 再扫描普通目录
807
+ for (const item of normalDirs) {
808
+ if (scanController.aborted) return;
809
+ const subDirPath = path.join(dir, item.name);
810
+ await scanDirectory(subDirPath, depth + 1);
811
+ }
812
+
813
+ } catch (error) {
814
+ // 忽略无法访问的目录
815
+ }
816
+
817
+ // 记录该深度的耗时
818
+ depthStats[depth].time += Date.now() - depthStart;
819
+ }
820
+
821
+ // 执行递归扫描
822
+ const scanStart = Date.now();
823
+ await scanDirectory(projectRoot, 0);
824
+
825
+ // 扫描完成,清除abort controller
826
+ if (currentScanAbortController === scanController) {
827
+ currentScanAbortController = null;
828
+ }
829
+
830
+ const scanTime = Date.now() - startTime;
831
+
832
+ if (scanController.aborted) {
833
+ logger.info(`npm脚本扫描被取消,耗时${scanTime}ms`);
834
+ return res.json({
835
+ success: true,
836
+ packages: [],
837
+ totalScripts: 0,
838
+ cancelled: true
839
+ });
840
+ }
841
+
842
+ // 输出每层深度的统计
843
+ const depthInfo = depthStats
844
+ .map((stat, depth) => stat.count > 0 ? `深度${depth}:${stat.count}个(${stat.time}ms)` : null)
845
+ .filter(Boolean)
846
+ .join(', ');
847
+
848
+
849
+ res.json({
850
+ success: true,
851
+ packages: packageJsons,
852
+ totalScripts: packageJsons.reduce((sum, pkg) => sum + Object.keys(pkg.scripts).length, 0)
853
+ });
854
+ } catch (error) {
855
+ logger.error('扫描npm脚本失败:', error);
856
+ res.status(500).json({
857
+ success: false,
858
+ error: `扫描npm脚本失败: ${error.message}`
859
+ });
860
+ }
861
+ });
862
+
863
+ // 扫描项目目录下的所有package.json文件(用于版本管理)
864
+ app.get('/api/scan-package-files', async (req, res) => {
865
+ try {
866
+ // 支持通过查询参数指定扫描目录,如果没有指定则使用当前工作目录
867
+ const customDirectory = req.query.directory;
868
+ const projectRoot = customDirectory || process.cwd();
869
+
870
+ // 验证目录是否存在且可访问
871
+ try {
872
+ const stats = await fs.stat(projectRoot);
873
+ if (!stats.isDirectory()) {
874
+ return res.status(400).json({
875
+ success: false,
876
+ error: '指定的路径不是一个有效的目录'
877
+ });
878
+ }
879
+ } catch (error) {
880
+ return res.status(400).json({
881
+ success: false,
882
+ error: `无法访问指定的目录: ${error.message}`
883
+ });
884
+ }
885
+
886
+ const packageFiles = [];
887
+ const startTime = Date.now();
888
+
889
+ // 需要忽略的目录列表
890
+ const IGNORED_DIRS = new Set([
891
+ 'node_modules',
892
+ '.git',
893
+ '.svn',
894
+ '.hg',
895
+ 'dist',
896
+ 'build',
897
+ 'coverage',
898
+ 'out',
899
+ 'target',
900
+ 'vendor',
901
+ '__pycache__',
902
+ '.next',
903
+ '.nuxt',
904
+ '.vscode',
905
+ '.idea',
906
+ 'tmp',
907
+ 'temp',
908
+ 'cache',
909
+ '.cache'
910
+ ]);
911
+
912
+ let scannedCount = 0;
913
+ let fileReadCount = 0;
914
+
915
+ // 检查指定目录下是否有package.json
916
+ async function checkPackageJson(dir) {
917
+ try {
918
+ const packagePath = path.join(dir, 'package.json');
919
+
920
+ // 先检查文件是否存在
921
+ try {
922
+ await fs.access(packagePath);
923
+ } catch {
924
+ return false;
925
+ }
926
+
927
+ // 检查文件大小
928
+ const stats = await fs.stat(packagePath);
929
+ const fileSizeMB = stats.size / (1024 * 1024);
930
+ if (fileSizeMB > 1) {
931
+ return false;
932
+ }
933
+
934
+ fileReadCount++;
935
+ const content = await fs.readFile(packagePath, 'utf8');
936
+ const packageData = JSON.parse(content);
937
+
938
+ // 添加所有有效的package.json文件(不仅仅是有scripts的)
939
+ if (packageData.name || packageData.version) {
940
+ const relativePath = path.relative(projectRoot, dir);
941
+ packageFiles.push({
942
+ path: dir,
943
+ relativePath: relativePath || '.',
944
+ name: packageData.name || path.basename(dir),
945
+ version: packageData.version || '0.0.0',
946
+ displayName: packageData.name ? `${packageData.name} (${packageData.version || '0.0.0'})` : `${path.basename(dir)} (${packageData.version || '0.0.0'})`,
947
+ fullPath: packagePath
948
+ });
949
+ return true;
950
+ }
951
+ } catch (error) {
952
+ // 文件不存在或解析失败,忽略
953
+ }
954
+ return false;
955
+ }
956
+
957
+ // 递归扫描目录,最大深度4层
958
+ const MAX_DEPTH = 4;
959
+ const MAX_DIRS_PER_LEVEL = 50;
960
+
961
+ async function scanDirectory(dir, depth = 0) {
962
+ if (depth > MAX_DEPTH) return;
963
+
964
+ scannedCount++;
965
+
966
+ // 检查当前目录的package.json
967
+ await checkPackageJson(dir);
968
+
969
+ // 如果已经达到最大深度,不再继续
970
+ if (depth >= MAX_DEPTH) return;
971
+
972
+ // 读取子目录
973
+ try {
974
+ const items = await fs.readdir(dir, { withFileTypes: true });
975
+ const subDirs = [];
976
+
977
+ // 收集所有子目录
978
+ for (const item of items) {
979
+ if (!item.isDirectory()) continue;
980
+
981
+ const dirName = item.name;
982
+
983
+ // 跳过忽略的目录
984
+ if (IGNORED_DIRS.has(dirName) || dirName.startsWith('.')) {
985
+ continue;
986
+ }
987
+
988
+ subDirs.push(item);
989
+ }
990
+
991
+ // 限制每层扫描的子目录数量
992
+ const dirsToScan = subDirs.slice(0, MAX_DIRS_PER_LEVEL);
993
+
994
+ // 递归扫描子目录
995
+ for (const item of dirsToScan) {
996
+ const subDirPath = path.join(dir, item.name);
997
+ await scanDirectory(subDirPath, depth + 1);
998
+ }
999
+ } catch (error) {
1000
+ // 忽略无法读取的目录
1001
+ }
1002
+ }
1003
+
1004
+ // 开始扫描
1005
+ await scanDirectory(projectRoot);
1006
+
1007
+ const scanTime = Date.now() - startTime;
1008
+
1009
+ res.json({
1010
+ success: true,
1011
+ packages: packageFiles,
1012
+ scanTime,
1013
+ scannedCount,
1014
+ fileReadCount
1015
+ });
1016
+ } catch (error) {
1017
+ logger.error('扫描package.json文件失败:', error);
1018
+ res.status(500).json({
1019
+ success: false,
1020
+ error: `扫描package.json文件失败: ${error.message}`
1021
+ });
1022
+ }
1023
+ });
1024
+
1025
+ // 在新终端中执行npm脚本
1026
+ app.post('/api/run-npm-script', async (req, res) => {
1027
+ try {
1028
+ const { packagePath, scriptName } = req.body;
1029
+
1030
+ if (!packagePath || !scriptName) {
1031
+ return res.status(400).json({
1032
+ success: false,
1033
+ error: '缺少必要参数:packagePath 和 scriptName'
1034
+ });
1035
+ }
1036
+
977
1037
  logger.info(`执行npm脚本: ${scriptName} in ${packagePath}`);
978
1038
 
979
1039
  // SEC-INJ-5 防御:scriptName 严格白名单(只允许 package.json scripts key),
@@ -1037,354 +1097,354 @@ export function registerNpmRoutes({
1037
1097
  message: `已在新终端中执行: ${scriptName}`,
1038
1098
  command: npmCommand,
1039
1099
  path: resolvedPkgPath
1040
- });
1041
-
1042
- res.json({
1043
- success: true,
1044
- message: `已在新终端中执行: ${scriptName}`,
1045
- command: npmCommand,
1046
- path: packagePath
1047
- });
1048
- } catch (error) {
1049
- logger.error('执行npm脚本失败:', error);
1050
- res.status(500).json({
1051
- success: false,
1052
- error: `执行npm脚本失败: ${error.message}`
1053
- });
1054
- }
1055
- });
1056
-
1057
- // API: 更新npm版本号
1058
- app.post('/api/update-npm-version', async (req, res) => {
1059
- try {
1060
- const { packagePath, versionType } = req.body;
1061
-
1062
- if (!packagePath || !versionType) {
1063
- return res.status(400).json({
1064
- success: false,
1065
- error: '缺少必要参数: packagePath, versionType'
1066
- });
1067
- }
1068
-
1069
- // 确保路径指向package.json文件
1070
- let packageJsonPath = path.resolve(packagePath);
1071
- if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1072
- packageJsonPath = path.join(packageJsonPath, 'package.json');
1073
- }
1074
-
1075
- // 检查文件是否存在
1076
- if (!fsSync.existsSync(packageJsonPath)) {
1077
- return res.status(404).json({
1078
- success: false,
1079
- error: '找不到package.json文件'
1080
- });
1081
- }
1082
-
1083
- // 读取package.json
1084
- const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1085
-
1086
- if (!packageJson.version) {
1087
- return res.status(400).json({
1088
- success: false,
1089
- error: 'package.json中没有version字段'
1090
- });
1091
- }
1092
-
1093
- const oldVersion = packageJson.version;
1094
- const versionParts = oldVersion.split('.').map(Number);
1095
-
1096
- // 根据类型增加版本号
1097
- switch (versionType) {
1098
- case 'major':
1099
- versionParts[0]++;
1100
- versionParts[1] = 0;
1101
- versionParts[2] = 0;
1102
- break;
1103
- case 'minor':
1104
- versionParts[1]++;
1105
- versionParts[2] = 0;
1106
- break;
1107
- case 'patch':
1108
- versionParts[2]++;
1109
- break;
1110
- default:
1111
- return res.status(400).json({
1112
- success: false,
1113
- error: '无效的版本类型,必须是 major, minor 或 patch'
1114
- });
1115
- }
1116
-
1117
- const newVersion = versionParts.join('.');
1118
- packageJson.version = newVersion;
1119
-
1120
- // 写回文件
1121
- fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1122
-
1123
- logger.info(`已更新npm版本号: ${oldVersion} → ${newVersion} (${packagePath})`);
1124
-
1125
- res.json({
1126
- success: true,
1127
- oldVersion,
1128
- newVersion
1129
- });
1130
- } catch (error) {
1131
- logger.error('更新版本号失败:', error);
1132
- res.status(500).json({
1133
- success: false,
1134
- error: `更新版本号失败: ${error.message}`
1135
- });
1136
- }
1137
- });
1138
-
1139
- // API: 添加npm脚本
1140
- app.post('/api/add-npm-script', async (req, res) => {
1141
- try {
1142
- const { packagePath, scriptName, scriptCommand } = req.body;
1143
-
1144
- if (!packagePath || !scriptName || !scriptCommand) {
1145
- return res.status(400).json({
1146
- success: false,
1147
- error: '缺少必要参数: packagePath, scriptName, scriptCommand'
1148
- });
1149
- }
1150
-
1151
- // 确保路径指向package.json文件
1152
- let packageJsonPath = path.resolve(packagePath);
1153
- if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1154
- packageJsonPath = path.join(packageJsonPath, 'package.json');
1155
- }
1156
-
1157
- // 检查文件是否存在
1158
- if (!fsSync.existsSync(packageJsonPath)) {
1159
- return res.status(404).json({
1160
- success: false,
1161
- error: '找不到package.json文件'
1162
- });
1163
- }
1164
-
1165
- // 读取package.json
1166
- const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1167
-
1168
- // 确保scripts对象存在
1169
- if (!packageJson.scripts) {
1170
- packageJson.scripts = {};
1171
- }
1172
-
1173
- // 检查脚本是否已存在
1174
- if (packageJson.scripts[scriptName]) {
1175
- return res.status(400).json({
1176
- success: false,
1177
- error: `脚本 "${scriptName}" 已存在`
1178
- });
1179
- }
1180
-
1181
- // 添加脚本
1182
- packageJson.scripts[scriptName] = scriptCommand;
1183
-
1184
- // 写回文件(保持格式化)
1185
- fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1186
-
1187
- logger.info(`已添加npm脚本: ${scriptName} = ${scriptCommand} (${packagePath})`);
1188
-
1189
- res.json({
1190
- success: true,
1191
- scriptName,
1192
- scriptCommand
1193
- });
1194
- } catch (error) {
1195
- logger.error('添加npm脚本失败:', error);
1196
- res.status(500).json({
1197
- success: false,
1198
- error: `添加npm脚本失败: ${error.message}`
1199
- });
1200
- }
1201
- });
1202
-
1203
- // API: 更新npm脚本
1204
- app.post('/api/update-npm-script', async (req, res) => {
1205
- try {
1206
- const { packagePath, scriptName, scriptCommand, oldScriptName } = req.body;
1207
-
1208
- if (!packagePath || !scriptName || !scriptCommand) {
1209
- return res.status(400).json({
1210
- success: false,
1211
- error: '缺少必要参数: packagePath, scriptName, scriptCommand'
1212
- });
1213
- }
1214
-
1215
- // 确保路径指向package.json文件
1216
- let packageJsonPath = path.resolve(packagePath);
1217
- if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1218
- packageJsonPath = path.join(packageJsonPath, 'package.json');
1219
- }
1220
-
1221
- // 检查文件是否存在
1222
- if (!fsSync.existsSync(packageJsonPath)) {
1223
- return res.status(404).json({
1224
- success: false,
1225
- error: '找不到package.json文件'
1226
- });
1227
- }
1228
-
1229
- // 读取package.json
1230
- const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1231
-
1232
- // 确保scripts对象存在
1233
- if (!packageJson.scripts) {
1234
- packageJson.scripts = {};
1235
- }
1236
-
1237
- // 如果改了脚本名称,删除旧的
1238
- if (oldScriptName && oldScriptName !== scriptName) {
1239
- delete packageJson.scripts[oldScriptName];
1240
- }
1241
-
1242
- // 更新脚本
1243
- packageJson.scripts[scriptName] = scriptCommand;
1244
-
1245
- // 写回文件
1246
- fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1247
-
1248
- logger.info(`已更新npm脚本: ${scriptName} = ${scriptCommand} (${packagePath})`);
1249
-
1250
- res.json({
1251
- success: true,
1252
- scriptName,
1253
- scriptCommand
1254
- });
1255
- } catch (error) {
1256
- logger.error('更新npm脚本失败:', error);
1257
- res.status(500).json({
1258
- success: false,
1259
- error: `更新npm脚本失败: ${error.message}`
1260
- });
1261
- }
1262
- });
1263
-
1264
- // API: 删除npm脚本
1265
- app.post('/api/delete-npm-script', async (req, res) => {
1266
- try {
1267
- const { packagePath, scriptName } = req.body;
1268
-
1269
- if (!packagePath || !scriptName) {
1270
- return res.status(400).json({
1271
- success: false,
1272
- error: '缺少必要参数: packagePath, scriptName'
1273
- });
1274
- }
1275
-
1276
- // 确保路径指向package.json文件
1277
- let packageJsonPath = path.resolve(packagePath);
1278
- if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1279
- packageJsonPath = path.join(packageJsonPath, 'package.json');
1280
- }
1281
-
1282
- // 检查文件是否存在
1283
- if (!fsSync.existsSync(packageJsonPath)) {
1284
- return res.status(404).json({
1285
- success: false,
1286
- error: '找不到package.json文件'
1287
- });
1288
- }
1289
-
1290
- // 读取package.json
1291
- const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1292
-
1293
- // 检查scripts对象和脚本是否存在
1294
- if (!packageJson.scripts || !packageJson.scripts[scriptName]) {
1295
- return res.status(404).json({
1296
- success: false,
1297
- error: `脚本 "${scriptName}" 不存在`
1298
- });
1299
- }
1300
-
1301
- // 删除脚本
1302
- delete packageJson.scripts[scriptName];
1303
-
1304
- // 写回文件
1305
- fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1306
-
1307
- logger.info(`已删除npm脚本: ${scriptName} (${packagePath})`);
1308
-
1309
- res.json({
1310
- success: true,
1311
- scriptName
1312
- });
1313
- } catch (error) {
1314
- logger.error('删除npm脚本失败:', error);
1315
- res.status(500).json({
1316
- success: false,
1317
- error: `删除npm脚本失败: ${error.message}`
1318
- });
1319
- }
1320
- });
1321
-
1322
- // API: 置顶npm脚本
1323
- app.post('/api/pin-npm-script', async (req, res) => {
1324
- try {
1325
- const { packagePath, scriptName } = req.body;
1326
-
1327
- if (!packagePath || !scriptName) {
1328
- return res.status(400).json({
1329
- success: false,
1330
- error: '缺少必要参数: packagePath, scriptName'
1331
- });
1332
- }
1333
-
1334
- // 确保路径指向package.json文件
1335
- let packageJsonPath = path.resolve(packagePath);
1336
- if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1337
- packageJsonPath = path.join(packageJsonPath, 'package.json');
1338
- }
1339
-
1340
- // 检查文件是否存在
1341
- if (!fsSync.existsSync(packageJsonPath)) {
1342
- return res.status(404).json({
1343
- success: false,
1344
- error: '找不到package.json文件'
1345
- });
1346
- }
1347
-
1348
- // 读取package.json
1349
- const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1350
-
1351
- // 检查scripts对象和脚本是否存在
1352
- if (!packageJson.scripts || !packageJson.scripts[scriptName]) {
1353
- return res.status(404).json({
1354
- success: false,
1355
- error: `脚本 "${scriptName}" 不存在`
1356
- });
1357
- }
1358
-
1359
- // 保存要置顶的脚本内容
1360
- const scriptCommand = packageJson.scripts[scriptName];
1361
-
1362
- // 删除该脚本
1363
- delete packageJson.scripts[scriptName];
1364
-
1365
- // 创建新的scripts对象,将置顶脚本放在最前面
1366
- const newScripts = {
1367
- [scriptName]: scriptCommand,
1368
- ...packageJson.scripts
1369
- };
1370
-
1371
- packageJson.scripts = newScripts;
1372
-
1373
- // 写回文件
1374
- fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1375
-
1376
- logger.info(`已置顶npm脚本: ${scriptName} (${packagePath})`);
1377
-
1378
- res.json({
1379
- success: true,
1380
- scriptName
1381
- });
1382
- } catch (error) {
1383
- logger.error('置顶npm脚本失败:', error);
1384
- res.status(500).json({
1385
- success: false,
1386
- error: `置顶npm脚本失败: ${error.message}`
1387
- });
1388
- }
1389
- });
1390
- }
1100
+ });
1101
+
1102
+ res.json({
1103
+ success: true,
1104
+ message: `已在新终端中执行: ${scriptName}`,
1105
+ command: npmCommand,
1106
+ path: packagePath
1107
+ });
1108
+ } catch (error) {
1109
+ logger.error('执行npm脚本失败:', error);
1110
+ res.status(500).json({
1111
+ success: false,
1112
+ error: `执行npm脚本失败: ${error.message}`
1113
+ });
1114
+ }
1115
+ });
1116
+
1117
+ // API: 更新npm版本号
1118
+ app.post('/api/update-npm-version', async (req, res) => {
1119
+ try {
1120
+ const { packagePath, versionType } = req.body;
1121
+
1122
+ if (!packagePath || !versionType) {
1123
+ return res.status(400).json({
1124
+ success: false,
1125
+ error: '缺少必要参数: packagePath, versionType'
1126
+ });
1127
+ }
1128
+
1129
+ // 确保路径指向package.json文件
1130
+ let packageJsonPath = path.resolve(packagePath);
1131
+ if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1132
+ packageJsonPath = path.join(packageJsonPath, 'package.json');
1133
+ }
1134
+
1135
+ // 检查文件是否存在
1136
+ if (!fsSync.existsSync(packageJsonPath)) {
1137
+ return res.status(404).json({
1138
+ success: false,
1139
+ error: '找不到package.json文件'
1140
+ });
1141
+ }
1142
+
1143
+ // 读取package.json
1144
+ const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1145
+
1146
+ if (!packageJson.version) {
1147
+ return res.status(400).json({
1148
+ success: false,
1149
+ error: 'package.json中没有version字段'
1150
+ });
1151
+ }
1152
+
1153
+ const oldVersion = packageJson.version;
1154
+ const versionParts = oldVersion.split('.').map(Number);
1155
+
1156
+ // 根据类型增加版本号
1157
+ switch (versionType) {
1158
+ case 'major':
1159
+ versionParts[0]++;
1160
+ versionParts[1] = 0;
1161
+ versionParts[2] = 0;
1162
+ break;
1163
+ case 'minor':
1164
+ versionParts[1]++;
1165
+ versionParts[2] = 0;
1166
+ break;
1167
+ case 'patch':
1168
+ versionParts[2]++;
1169
+ break;
1170
+ default:
1171
+ return res.status(400).json({
1172
+ success: false,
1173
+ error: '无效的版本类型,必须是 major, minor 或 patch'
1174
+ });
1175
+ }
1176
+
1177
+ const newVersion = versionParts.join('.');
1178
+ packageJson.version = newVersion;
1179
+
1180
+ // 写回文件
1181
+ fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1182
+
1183
+ logger.info(`已更新npm版本号: ${oldVersion} → ${newVersion} (${packagePath})`);
1184
+
1185
+ res.json({
1186
+ success: true,
1187
+ oldVersion,
1188
+ newVersion
1189
+ });
1190
+ } catch (error) {
1191
+ logger.error('更新版本号失败:', error);
1192
+ res.status(500).json({
1193
+ success: false,
1194
+ error: `更新版本号失败: ${error.message}`
1195
+ });
1196
+ }
1197
+ });
1198
+
1199
+ // API: 添加npm脚本
1200
+ app.post('/api/add-npm-script', async (req, res) => {
1201
+ try {
1202
+ const { packagePath, scriptName, scriptCommand } = req.body;
1203
+
1204
+ if (!packagePath || !scriptName || !scriptCommand) {
1205
+ return res.status(400).json({
1206
+ success: false,
1207
+ error: '缺少必要参数: packagePath, scriptName, scriptCommand'
1208
+ });
1209
+ }
1210
+
1211
+ // 确保路径指向package.json文件
1212
+ let packageJsonPath = path.resolve(packagePath);
1213
+ if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1214
+ packageJsonPath = path.join(packageJsonPath, 'package.json');
1215
+ }
1216
+
1217
+ // 检查文件是否存在
1218
+ if (!fsSync.existsSync(packageJsonPath)) {
1219
+ return res.status(404).json({
1220
+ success: false,
1221
+ error: '找不到package.json文件'
1222
+ });
1223
+ }
1224
+
1225
+ // 读取package.json
1226
+ const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1227
+
1228
+ // 确保scripts对象存在
1229
+ if (!packageJson.scripts) {
1230
+ packageJson.scripts = {};
1231
+ }
1232
+
1233
+ // 检查脚本是否已存在
1234
+ if (packageJson.scripts[scriptName]) {
1235
+ return res.status(400).json({
1236
+ success: false,
1237
+ error: `脚本 "${scriptName}" 已存在`
1238
+ });
1239
+ }
1240
+
1241
+ // 添加脚本
1242
+ packageJson.scripts[scriptName] = scriptCommand;
1243
+
1244
+ // 写回文件(保持格式化)
1245
+ fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1246
+
1247
+ logger.info(`已添加npm脚本: ${scriptName} = ${scriptCommand} (${packagePath})`);
1248
+
1249
+ res.json({
1250
+ success: true,
1251
+ scriptName,
1252
+ scriptCommand
1253
+ });
1254
+ } catch (error) {
1255
+ logger.error('添加npm脚本失败:', error);
1256
+ res.status(500).json({
1257
+ success: false,
1258
+ error: `添加npm脚本失败: ${error.message}`
1259
+ });
1260
+ }
1261
+ });
1262
+
1263
+ // API: 更新npm脚本
1264
+ app.post('/api/update-npm-script', async (req, res) => {
1265
+ try {
1266
+ const { packagePath, scriptName, scriptCommand, oldScriptName } = req.body;
1267
+
1268
+ if (!packagePath || !scriptName || !scriptCommand) {
1269
+ return res.status(400).json({
1270
+ success: false,
1271
+ error: '缺少必要参数: packagePath, scriptName, scriptCommand'
1272
+ });
1273
+ }
1274
+
1275
+ // 确保路径指向package.json文件
1276
+ let packageJsonPath = path.resolve(packagePath);
1277
+ if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1278
+ packageJsonPath = path.join(packageJsonPath, 'package.json');
1279
+ }
1280
+
1281
+ // 检查文件是否存在
1282
+ if (!fsSync.existsSync(packageJsonPath)) {
1283
+ return res.status(404).json({
1284
+ success: false,
1285
+ error: '找不到package.json文件'
1286
+ });
1287
+ }
1288
+
1289
+ // 读取package.json
1290
+ const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1291
+
1292
+ // 确保scripts对象存在
1293
+ if (!packageJson.scripts) {
1294
+ packageJson.scripts = {};
1295
+ }
1296
+
1297
+ // 如果改了脚本名称,删除旧的
1298
+ if (oldScriptName && oldScriptName !== scriptName) {
1299
+ delete packageJson.scripts[oldScriptName];
1300
+ }
1301
+
1302
+ // 更新脚本
1303
+ packageJson.scripts[scriptName] = scriptCommand;
1304
+
1305
+ // 写回文件
1306
+ fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1307
+
1308
+ logger.info(`已更新npm脚本: ${scriptName} = ${scriptCommand} (${packagePath})`);
1309
+
1310
+ res.json({
1311
+ success: true,
1312
+ scriptName,
1313
+ scriptCommand
1314
+ });
1315
+ } catch (error) {
1316
+ logger.error('更新npm脚本失败:', error);
1317
+ res.status(500).json({
1318
+ success: false,
1319
+ error: `更新npm脚本失败: ${error.message}`
1320
+ });
1321
+ }
1322
+ });
1323
+
1324
+ // API: 删除npm脚本
1325
+ app.post('/api/delete-npm-script', async (req, res) => {
1326
+ try {
1327
+ const { packagePath, scriptName } = req.body;
1328
+
1329
+ if (!packagePath || !scriptName) {
1330
+ return res.status(400).json({
1331
+ success: false,
1332
+ error: '缺少必要参数: packagePath, scriptName'
1333
+ });
1334
+ }
1335
+
1336
+ // 确保路径指向package.json文件
1337
+ let packageJsonPath = path.resolve(packagePath);
1338
+ if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1339
+ packageJsonPath = path.join(packageJsonPath, 'package.json');
1340
+ }
1341
+
1342
+ // 检查文件是否存在
1343
+ if (!fsSync.existsSync(packageJsonPath)) {
1344
+ return res.status(404).json({
1345
+ success: false,
1346
+ error: '找不到package.json文件'
1347
+ });
1348
+ }
1349
+
1350
+ // 读取package.json
1351
+ const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1352
+
1353
+ // 检查scripts对象和脚本是否存在
1354
+ if (!packageJson.scripts || !packageJson.scripts[scriptName]) {
1355
+ return res.status(404).json({
1356
+ success: false,
1357
+ error: `脚本 "${scriptName}" 不存在`
1358
+ });
1359
+ }
1360
+
1361
+ // 删除脚本
1362
+ delete packageJson.scripts[scriptName];
1363
+
1364
+ // 写回文件
1365
+ fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1366
+
1367
+ logger.info(`已删除npm脚本: ${scriptName} (${packagePath})`);
1368
+
1369
+ res.json({
1370
+ success: true,
1371
+ scriptName
1372
+ });
1373
+ } catch (error) {
1374
+ logger.error('删除npm脚本失败:', error);
1375
+ res.status(500).json({
1376
+ success: false,
1377
+ error: `删除npm脚本失败: ${error.message}`
1378
+ });
1379
+ }
1380
+ });
1381
+
1382
+ // API: 置顶npm脚本
1383
+ app.post('/api/pin-npm-script', async (req, res) => {
1384
+ try {
1385
+ const { packagePath, scriptName } = req.body;
1386
+
1387
+ if (!packagePath || !scriptName) {
1388
+ return res.status(400).json({
1389
+ success: false,
1390
+ error: '缺少必要参数: packagePath, scriptName'
1391
+ });
1392
+ }
1393
+
1394
+ // 确保路径指向package.json文件
1395
+ let packageJsonPath = path.resolve(packagePath);
1396
+ if (fsSync.existsSync(packageJsonPath) && fsSync.statSync(packageJsonPath).isDirectory()) {
1397
+ packageJsonPath = path.join(packageJsonPath, 'package.json');
1398
+ }
1399
+
1400
+ // 检查文件是否存在
1401
+ if (!fsSync.existsSync(packageJsonPath)) {
1402
+ return res.status(404).json({
1403
+ success: false,
1404
+ error: '找不到package.json文件'
1405
+ });
1406
+ }
1407
+
1408
+ // 读取package.json
1409
+ const packageJson = JSON.parse(fsSync.readFileSync(packageJsonPath, 'utf8'));
1410
+
1411
+ // 检查scripts对象和脚本是否存在
1412
+ if (!packageJson.scripts || !packageJson.scripts[scriptName]) {
1413
+ return res.status(404).json({
1414
+ success: false,
1415
+ error: `脚本 "${scriptName}" 不存在`
1416
+ });
1417
+ }
1418
+
1419
+ // 保存要置顶的脚本内容
1420
+ const scriptCommand = packageJson.scripts[scriptName];
1421
+
1422
+ // 删除该脚本
1423
+ delete packageJson.scripts[scriptName];
1424
+
1425
+ // 创建新的scripts对象,将置顶脚本放在最前面
1426
+ const newScripts = {
1427
+ [scriptName]: scriptCommand,
1428
+ ...packageJson.scripts
1429
+ };
1430
+
1431
+ packageJson.scripts = newScripts;
1432
+
1433
+ // 写回文件
1434
+ fsSync.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8');
1435
+
1436
+ logger.info(`已置顶npm脚本: ${scriptName} (${packagePath})`);
1437
+
1438
+ res.json({
1439
+ success: true,
1440
+ scriptName
1441
+ });
1442
+ } catch (error) {
1443
+ logger.error('置顶npm脚本失败:', error);
1444
+ res.status(500).json({
1445
+ success: false,
1446
+ error: `置顶npm脚本失败: ${error.message}`
1447
+ });
1448
+ }
1449
+ });
1450
+ }