zen-gitsync 2.16.31 → 2.16.33

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 (75) hide show
  1. package/README.md +31 -0
  2. package/package.json +2 -2
  3. package/src/config.js +12 -2
  4. package/src/ui/public/assets/AgentView-D9y5maA3.js +4 -0
  5. package/src/ui/public/assets/AppVersionBadge-CsKq0HLG.js +2 -0
  6. package/src/ui/public/assets/BranchSelector-C09wdMBR.js +1 -0
  7. package/src/ui/public/assets/CommitForm-wTVAlj8K.js +6 -0
  8. package/src/ui/public/assets/CommonDialog-gF_3v5QY.js +1 -0
  9. package/src/ui/public/assets/EditorView-BYaXYY5H.js +1 -0
  10. package/src/ui/public/assets/FlowExecutionViewer-DvSe3GqC.js +1 -0
  11. package/src/ui/public/assets/FlowOrchestrationWorkspace-FYLZxzMC.js +21 -0
  12. package/src/ui/public/assets/LogList-BB56Ma_-.js +7 -0
  13. package/src/ui/public/assets/MindmapView-B57NJ-tK.js +2 -0
  14. package/src/ui/public/assets/MonitorView-DujOLv6G.js +1 -0
  15. package/src/ui/public/assets/ProjectStartupButton-B0vIxM8Y.js +1 -0
  16. package/src/ui/public/assets/RemoteRepoCard-Tdhzf-Jb.js +5 -0
  17. package/src/ui/public/assets/SourceMapView-Bf7__bEW.js +3 -0
  18. package/src/ui/public/assets/SvgIcon-DV0F5fm7.js +1 -0
  19. package/src/ui/public/assets/UserInputNode-ZatmWGxI.js +1 -0
  20. package/src/ui/public/assets/WorkbenchView-DVjvpQMi.js +28 -0
  21. package/src/ui/public/assets/_plugin-vue_export-helper-DkdPQgM8.js +17 -0
  22. package/src/ui/public/assets/configStore-C2xoYf_U.js +1 -0
  23. package/src/ui/public/assets/element-plus-BrZIKMTV.js +47 -0
  24. package/src/ui/public/assets/{flow-mindmap-C0_IdibG.css → flow-mindmap-DnzM9SlK.css} +1 -1
  25. package/src/ui/public/assets/flow-mindmap-uF7lcnUF.js +139 -0
  26. package/src/ui/public/assets/index-DDa4fUq_.js +79 -0
  27. package/src/ui/public/assets/index-DkMUEB4_.css +1 -0
  28. package/src/ui/public/assets/office-docx-DZfJaVF6.js +32 -0
  29. package/src/ui/public/assets/office-excel-BhBLQBXN.js +170 -0
  30. package/src/ui/public/assets/office-pptx-C0yb5pVU.js +74 -0
  31. package/src/ui/public/assets/toolsStore-Dpb3rc-o.js +1 -0
  32. package/src/ui/public/assets/{vendor-Cx8PA1NP.js → vendor-BuJWEuf4.js} +43 -43
  33. package/src/ui/public/assets/vue-flow-CrEicpfB.js +39 -0
  34. package/src/ui/public/index.html +12 -12
  35. package/src/ui/server/index.js +9 -23
  36. package/src/ui/server/middleware/originGuard.js +138 -0
  37. package/src/ui/server/middleware/originGuard.test.js +125 -0
  38. package/src/ui/server/routes/fs.js +83 -3
  39. package/src/ui/server/routes/git/diff.js +12 -4
  40. package/src/ui/server/routes/git/stash.js +39 -29
  41. package/src/ui/server/routes/git/tags.js +23 -16
  42. package/src/ui/server/routes/git.js +17 -11
  43. package/src/ui/server/routes/gitOps.js +126 -47
  44. package/src/ui/server/routes/npm.js +36 -10
  45. package/src/ui/server/utils/gitArgs.js +165 -0
  46. package/src/ui/server/utils/gitArgs.test.js +215 -0
  47. package/src/ui/server/utils/officePreview.js +80 -0
  48. package/src/ui/server/utils/officePreview.test.js +21 -0
  49. package/src/ui/server/utils/startServerOnAvailablePort.js +20 -2
  50. package/src/ui/server/utils/startServerOnAvailablePort.test.js +181 -0
  51. package/src/ui/public/assets/AgentView-DPan52IZ.js +0 -4
  52. package/src/ui/public/assets/AppVersionBadge-CiorBQrl.js +0 -2
  53. package/src/ui/public/assets/BranchSelector-OQWGKJq8.js +0 -1
  54. package/src/ui/public/assets/CommitForm-Cq1UUNM4.js +0 -6
  55. package/src/ui/public/assets/CommonDialog-DJqOwkbm.js +0 -1
  56. package/src/ui/public/assets/EditorView-0HmbFAex.js +0 -1
  57. package/src/ui/public/assets/FlowExecutionViewer-DjIAKDQt.js +0 -1
  58. package/src/ui/public/assets/FlowOrchestrationWorkspace-CCs7sMOO.js +0 -21
  59. package/src/ui/public/assets/LogList-De9E23D1.js +0 -7
  60. package/src/ui/public/assets/MindmapView-CpyaWr7b.js +0 -2
  61. package/src/ui/public/assets/MonitorView-DVwS51YV.js +0 -1
  62. package/src/ui/public/assets/ProjectStartupButton-DiaZ8xiI.js +0 -1
  63. package/src/ui/public/assets/RemoteRepoCard-B299cQhO.js +0 -5
  64. package/src/ui/public/assets/SourceMapView-C7vO_5Hz.js +0 -3
  65. package/src/ui/public/assets/SvgIcon-B9XhBoIQ.js +0 -1
  66. package/src/ui/public/assets/UserInputNode-CeNehn29.js +0 -1
  67. package/src/ui/public/assets/WorkbenchView-Be4GcjdO.js +0 -28
  68. package/src/ui/public/assets/_plugin-vue_export-helper-aENhMEEX.js +0 -17
  69. package/src/ui/public/assets/configStore-BvJZNumP.js +0 -1
  70. package/src/ui/public/assets/element-plus-B3pTS_5R.js +0 -32
  71. package/src/ui/public/assets/flow-mindmap-hUZCS6o1.js +0 -138
  72. package/src/ui/public/assets/index-CuD0oK0z.js +0 -79
  73. package/src/ui/public/assets/index-zmReBTKj.css +0 -1
  74. package/src/ui/public/assets/toolsStore-D9gXnqwO.js +0 -1
  75. package/src/ui/public/assets/vue-flow-COROsN-V.js +0 -39
@@ -24,6 +24,14 @@ import { registerAiDiffSummaryRoutes } from './git/aiDiffSummary.js';
24
24
  import { createDiffHelpers } from './git/diffUtils.js';
25
25
  import { registerGitStashRoutes } from './git/stash.js';
26
26
  import { registerGitTagRoutes } from './git/tags.js';
27
+ import {
28
+ assertGitRef,
29
+ assertGitHash,
30
+ assertGitPath,
31
+ assertGitRemoteUrl,
32
+ assertGitConfigKey,
33
+ assertGitConfigValue
34
+ } from '../utils/gitArgs.js';
27
35
 
28
36
  export function registerGitOpsRoutes({
29
37
  app,
@@ -229,7 +237,10 @@ export function registerGitOpsRoutes({
229
237
  await execGitCommand(['add', '--', filePath]);
230
238
  res.json({ success: true });
231
239
  } catch (error) {
232
- res.status(500).json({ success: false, error: error.message });
240
+ res.status(error?.statusCode || 500).json({
241
+ success: false,
242
+ error: error.message
243
+ });
233
244
  }
234
245
  });
235
246
 
@@ -259,7 +270,8 @@ export function registerGitOpsRoutes({
259
270
  }
260
271
 
261
272
  // 一次 git add 命令暂存所有文件(不再需要 shell 转义,execFile argv 数组天然支持任意路径)
262
- await execGitCommand(['add', ...uniquePaths]);
273
+ // 前插 '--':文件名若以 - 开头会被 git 当成选项,-- 之后一律按路径处理
274
+ await execGitCommand(['add', '--', ...uniquePaths]);
263
275
 
264
276
  res.json({
265
277
  success: true,
@@ -331,6 +343,9 @@ export function registerGitOpsRoutes({
331
343
  if (!branch || typeof branch !== 'string') {
332
344
  return res.status(400).json({ success: false, error: '缺少 branch 参数' });
333
345
  }
346
+ // branch 落在 git 的参数位置上,不做形状校验的话传 '--upload-pack=curl x|sh'
347
+ // 就能让 git 执行命令(execFile 无 shell 也拦不住)。
348
+ const safeBranch = assertGitRef(branch, '分支名');
334
349
  // 防御性检查:空仓库(没有任何 commit)直接返回明确错误,
335
350
  // 避免 git push -u 报 "src refspec master does not match any" 让用户摸不着头脑。
336
351
  // git rev-parse --verify HEAD 在空仓库下返回非零退出 → execGitCommand 用 ignoreError
@@ -346,7 +361,7 @@ export function registerGitOpsRoutes({
346
361
  errorCode: 'EMPTY_REPO'
347
362
  });
348
363
  }
349
- const { stdout } = await execGitCommand(['push', '-u', 'origin', branch]);
364
+ const { stdout } = await execGitCommand(['push', '-u', 'origin', safeBranch]);
350
365
  setRecentPushStatus({
351
366
  justPushed: true,
352
367
  pushTime: Date.now(),
@@ -355,7 +370,8 @@ export function registerGitOpsRoutes({
355
370
  logger.info(`推送并设置上游成功: origin/${branch}`);
356
371
  res.json({ success: true, message: stdout });
357
372
  } catch (error) {
358
- res.status(500).json({ success: false, error: error.message });
373
+ // assertGitRef 抛的是 HttpError(400),别吞成 500
374
+ res.status(error?.statusCode || 500).json({ success: false, error: error.message });
359
375
  }
360
376
  });
361
377
 
@@ -368,15 +384,16 @@ export function registerGitOpsRoutes({
368
384
  if (!key) {
369
385
  return res.status(400).json({ success: false, error: '缺少 key 参数' });
370
386
  }
387
+ const safeKey = assertGitConfigKey(key);
371
388
  try {
372
- const { stdout } = await execGitCommand(['config', '--global', '--get', key], { log: false });
389
+ const { stdout } = await execGitCommand(['config', '--global', '--get', safeKey], { log: false });
373
390
  return res.json({ success: true, value: String(stdout || '').trim() });
374
391
  } catch (err) {
375
392
  // git --get 在 key 不存在时返回非零退出码 → 视为"未设置",返回空串
376
393
  return res.json({ success: true, value: '' });
377
394
  }
378
395
  } catch (error) {
379
- res.status(500).json({ success: false, error: error.message });
396
+ res.status(error?.statusCode || 500).json({ success: false, error: error.message });
380
397
  }
381
398
  });
382
399
 
@@ -389,15 +406,18 @@ export function registerGitOpsRoutes({
389
406
  if (!key || typeof key !== 'string') {
390
407
  return res.status(400).json({ success: false, error: '缺少 key 参数' });
391
408
  }
392
- const trimmed = typeof value === 'string' ? value.trim() : '';
409
+ // key 走白名单(挡住 core.editor / core.pager / http.*.extraheader / alias.*),
410
+ // value 挡换行 —— 否则值里的 \n 会在 .gitconfig 里凭空造出一行新 key
411
+ const safeKey = assertGitConfigKey(key);
412
+ const trimmed = assertGitConfigValue(value);
393
413
  if (!trimmed) {
394
- await execGitCommand(['config', '--global', '--unset', key]);
414
+ await execGitCommand(['config', '--global', '--unset', safeKey]);
395
415
  } else {
396
- await execGitCommand(['config', '--global', key, trimmed]);
416
+ await execGitCommand(['config', '--global', safeKey, trimmed]);
397
417
  }
398
418
  res.json({ success: true });
399
419
  } catch (error) {
400
- res.status(500).json({ success: false, error: error.message });
420
+ res.status(error?.statusCode || 500).json({ success: false, error: error.message });
401
421
  }
402
422
  });
403
423
 
@@ -409,9 +429,18 @@ export function registerGitOpsRoutes({
409
429
  res.setHeader('Connection', 'keep-alive');
410
430
  res.flushHeaders();
411
431
 
432
+ // 客户端断开后 res.write 抛 EPIPE,若不吞掉,异常会从 gitPush.stderr 的
433
+ // 'data' 监听器冒泡成 uncaughtException → index.js 的 fatalExit → 整个服务退出。
434
+ let clientClosed = false;
412
435
  const sendProgress = (data) => {
413
- res.write(`data: ${JSON.stringify(data)}\n\n`);
436
+ if (clientClosed) return;
437
+ try {
438
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
439
+ } catch {
440
+ // res 已关闭,忽略
441
+ }
414
442
  };
443
+ const finish = () => { try { res.end(); } catch { /* ignore */ } };
415
444
 
416
445
  try {
417
446
  // 获取当前工作目录 - 与execGitCommand保持一致
@@ -435,7 +464,7 @@ export function registerGitOpsRoutes({
435
464
  error: '当前仓库没有任何提交,请先在左侧暂存并提交至少一个文件后再推送。',
436
465
  errorCode: 'EMPTY_REPO'
437
466
  });
438
- return res.end();
467
+ return finish();
439
468
  }
440
469
 
441
470
  logger.info('开始推送,工作目录:', workDir);
@@ -558,7 +587,7 @@ export function registerGitOpsRoutes({
558
587
  error: errorOutput || standardOutput || `Push failed with code ${code}`
559
588
  });
560
589
  }
561
- res.end();
590
+ finish();
562
591
  });
563
592
 
564
593
  gitPush.on('error', (error) => {
@@ -581,7 +610,27 @@ export function registerGitOpsRoutes({
581
610
  success: false,
582
611
  error: error.message
583
612
  });
584
- res.end();
613
+ finish();
614
+ });
615
+
616
+ // 客户端断连 → kill 掉 git push。推送在远端是原子的(要么整体接受要么整体拒绝),
617
+ // 中断不会留下半截引用;不 kill 则会残留孤儿进程直到推送结束。
618
+ req.on('close', () => {
619
+ if (clientClosed) return;
620
+ clientClosed = true;
621
+ if (gitPush && !gitPush.killed) {
622
+ try {
623
+ gitPush.kill('SIGTERM');
624
+ setTimeout(() => {
625
+ if (!gitPush.killed) {
626
+ try { gitPush.kill('SIGKILL'); } catch { /* ignore */ }
627
+ }
628
+ }, 2000);
629
+ } catch (e) {
630
+ logger.warn('[push-with-progress] 客户端断连后 kill 子进程失败:', e?.message || e);
631
+ }
632
+ }
633
+ finish();
585
634
  });
586
635
 
587
636
  } catch (error) {
@@ -599,7 +648,7 @@ export function registerGitOpsRoutes({
599
648
  success: false,
600
649
  error: error.message
601
650
  });
602
- res.end();
651
+ finish();
603
652
  }
604
653
  });
605
654
 
@@ -657,7 +706,11 @@ export function registerGitOpsRoutes({
657
706
  const message = req.query.message || '';
658
707
  const dateFrom = req.query.dateFrom || '';
659
708
  const dateTo = req.query.dateTo || '';
660
- const branch = req.query.branch ? req.query.branch.split(',') : [];
709
+ // branch 最终会原样进 git 的参数位(见 executeGitLogCommand:含 '/' 的不加
710
+ // refs/heads/ 前缀),所以 '--output=/path' 这类字符串能被 git 解析成选项。
711
+ const branch = req.query.branch
712
+ ? req.query.branch.split(',').map(b => b.trim()).filter(Boolean).map(b => assertGitRef(b, '分支名'))
713
+ : [];
661
714
  const withParents = req.query.with_parents === 'true';
662
715
 
663
716
  // 如果指定了分支,直接走 executeGitLogCommand 处理 refs/ 前缀
@@ -716,7 +769,8 @@ export function registerGitOpsRoutes({
716
769
  processAndSendLogOutput(res, logOutput, page, limit, withParents);
717
770
  } catch (error) {
718
771
  logger.error('获取Git日志失败:', error);
719
- res.status(500).json({ error: '获取日志失败: ' + error.message });
772
+ // 参数校验失败是 HttpError(400),不该被一律吞成 500
773
+ res.status(error?.statusCode || 500).json({ error: '获取日志失败: ' + error.message });
720
774
  }
721
775
  });
722
776
 
@@ -785,7 +839,7 @@ export function registerGitOpsRoutes({
785
839
  processAndSendLogOutput(res, logOutput, skip / limit + 1, limit, withParents);
786
840
  } catch (error) {
787
841
  logger.error('执行Git日志命令失败:', error);
788
- res.status(500).json({ error: '获取日志失败: ' + error.message });
842
+ res.status(error?.statusCode || 500).json({ error: '获取日志失败: ' + error.message });
789
843
  }
790
844
  }
791
845
 
@@ -891,6 +945,8 @@ export function registerGitOpsRoutes({
891
945
  error: '缺少分支名称参数'
892
946
  });
893
947
  }
948
+ // 拼成 origin/<branch> 后进 git 参数位,同样要过 ref 校验
949
+ const safeBranch = assertGitRef(branch, '分支名');
894
950
 
895
951
  // 尝试清理 Git 锁文件(破坏性入口:force=true 绕过 mtime 判断,
896
952
  // 仍走 PID liveness 校验——用户主动触发的 reset --hard 不该被
@@ -898,11 +954,11 @@ export function registerGitOpsRoutes({
898
954
  await checkAndClearGitLock({ force: true });
899
955
 
900
956
  // 执行 git reset --hard origin/branch 命令
901
- await execGitCommand(['reset', '--hard', `origin/${branch}`]);
957
+ await execGitCommand(['reset', '--hard', `origin/${safeBranch}`]);
902
958
  res.json({ success: true });
903
959
  } catch (error) {
904
960
  logger.error('重置到远程分支失败:', error);
905
- res.status(500).json({
961
+ res.status(error?.statusCode || 500).json({
906
962
  success: false,
907
963
  error: `重置到远程分支失败: ${error.message}`
908
964
  });
@@ -944,11 +1000,12 @@ export function registerGitOpsRoutes({
944
1000
  error: '缺少提交哈希参数'
945
1001
  });
946
1002
  }
1003
+ const safeHash = assertGitHash(hash);
947
1004
 
948
- logger.info(`获取提交文件列表: hash=${hash}`);
1005
+ logger.info(`获取提交文件列表: hash=${safeHash}`);
949
1006
 
950
1007
  // 执行命令获取提交中修改的文件列表
951
- const { stdout } = await execGitCommand(['show', '--name-only', '--format=', hash]);
1008
+ const { stdout } = await execGitCommand(['show', '--name-only', '--format=', safeHash]);
952
1009
 
953
1010
  // 将输出按行分割,并过滤掉空行
954
1011
  const files = stdout.split('\n').filter(line => line.trim());
@@ -960,7 +1017,7 @@ export function registerGitOpsRoutes({
960
1017
  });
961
1018
  } catch (error) {
962
1019
  logger.error('获取提交文件列表失败:', error);
963
- res.status(500).json({
1020
+ res.status(error?.statusCode || 500).json({
964
1021
  success: false,
965
1022
  error: `获取提交文件列表失败: ${error.message}`
966
1023
  });
@@ -980,14 +1037,20 @@ export function registerGitOpsRoutes({
980
1037
  });
981
1038
  }
982
1039
 
983
- logger.info(`获取提交文件差异: hash=${hash}, file=${filePath}`);
1040
+ // hash 落在 `--` 之前,会被 git 当 rev 解析;以 - 开头就变成选项了
1041
+ // (git show 有 --output=,能写任意文件)。filePath 虽有 -- 保护,
1042
+ // 但仍要挡住绝对路径与 .. 上跳。
1043
+ const safeHash = assertGitHash(hash);
1044
+ const safePath = assertGitPath(String(filePath).replace(/^\/+/, ''));
1045
+
1046
+ logger.info(`获取提交文件差异: hash=${safeHash}, file=${safePath}`);
984
1047
 
985
- const diffCommandArgs = ['show', hash, '--', filePath];
1048
+ const diffCommandArgs = ['show', safeHash, '--', safePath];
986
1049
 
987
1050
  // 使用优化的检查函数
988
1051
  // checkShouldSkipDiff 接受字符串命令,这里只用于大小判断,先按字面占位;
989
1052
  // 走 execGitCommand 时仍用 argv 数组保证转义正确。
990
- const skipCheck = await checkShouldSkipDiff(filePath, `git show ${hash} -- "${filePath}"`);
1053
+ const skipCheck = await checkShouldSkipDiff(safePath, `git show ${safeHash} -- "${safePath}"`);
991
1054
  if (skipCheck.shouldSkip) {
992
1055
  return res.json({
993
1056
  success: true,
@@ -1021,7 +1084,7 @@ export function registerGitOpsRoutes({
1021
1084
  });
1022
1085
  } catch (error) {
1023
1086
  logger.error('获取提交文件差异失败:', error);
1024
- res.status(500).json({
1087
+ res.status(error?.statusCode || 500).json({
1025
1088
  success: false,
1026
1089
  error: `获取提交文件差异失败: ${error.message}`
1027
1090
  });
@@ -1041,8 +1104,16 @@ export function registerGitOpsRoutes({
1041
1104
  });
1042
1105
  }
1043
1106
 
1107
+ // hash 与 filePath 都要先过校验:spec 是 `<rev>:<path>` 复合形式,
1108
+ // 任一环节带 - 开头都会被 git 当选项。hash 只接受十六进制(^ 由下面自己拼,
1109
+ // 不交给外部传入),filePath 只接受仓库内的相对路径。
1110
+ const safeHash = assertGitHash(hash);
1111
+ // git 的 <rev>:<path> 里 /src/foo.ts 与 src/foo.ts 等价(都相对仓库根),
1112
+ // 去掉前导 / 再校验,避免正常路径被误拒
1113
+ const safePath = assertGitPath(String(filePath).replace(/^\/+/, ''));
1114
+
1044
1115
  const isOld = String(version || 'new') === 'old';
1045
- const targetHash = isOld ? `${hash}^` : `${hash}`;
1116
+ const targetHash = isOld ? `${safeHash}^` : `${safeHash}`;
1046
1117
 
1047
1118
  const skipExtensions = /\.(min\.js|umd\.cjs|bundle\.js|dist\.js|prod\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;
1048
1119
  if (skipExtensions.test(String(filePath))) {
@@ -1053,7 +1124,7 @@ export function registerGitOpsRoutes({
1053
1124
  });
1054
1125
  }
1055
1126
 
1056
- const spec = `${targetHash}:${filePath}`;
1127
+ const spec = `${targetHash}:${safePath}`;
1057
1128
 
1058
1129
  let sizeBytes = 0;
1059
1130
  try {
@@ -1086,13 +1157,14 @@ export function registerGitOpsRoutes({
1086
1157
  content: stdout ?? ''
1087
1158
  });
1088
1159
  } catch (error) {
1089
- res.status(500).json({
1160
+ res.status(error?.statusCode || 500).json({
1090
1161
  success: false,
1091
1162
  error: `获取文件内容失败: ${error.message}`
1092
1163
  });
1093
1164
  }
1094
1165
  } catch (error) {
1095
- res.status(500).json({
1166
+ // assertGitHash / assertGitPath 抛的是 HttpError(400),别吞成 500
1167
+ res.status(error?.statusCode || 500).json({
1096
1168
  success: false,
1097
1169
  error: error.message
1098
1170
  });
@@ -1111,18 +1183,20 @@ export function registerGitOpsRoutes({
1111
1183
  });
1112
1184
  }
1113
1185
 
1114
- logger.info(`执行撤销提交操作: hash=${hash}`);
1186
+ const safeHash = assertGitHash(hash);
1187
+ logger.info(`执行撤销提交操作: hash=${safeHash}`);
1115
1188
 
1116
1189
  // 执行git revert命令
1117
- await execGitCommand(['revert', '--no-edit', hash]);
1190
+ await execGitCommand(['revert', '--no-edit', safeHash]);
1118
1191
 
1119
1192
  res.json({
1120
1193
  success: true,
1121
- message: `已成功撤销提交 ${hash}`
1194
+ message: `已成功撤销提交 ${safeHash}`
1122
1195
  });
1123
1196
  } catch (error) {
1124
1197
  logger.error('撤销提交失败:', error);
1125
- res.status(500).json({
1198
+ // 校验失败是 HttpError(400),不该被一律吞成 500
1199
+ res.status(error?.statusCode || 500).json({
1126
1200
  success: false,
1127
1201
  error: `撤销提交失败: ${error.message}`
1128
1202
  });
@@ -1141,18 +1215,19 @@ export function registerGitOpsRoutes({
1141
1215
  });
1142
1216
  }
1143
1217
 
1144
- logger.info(`执行Cherry-pick操作: hash=${hash}`);
1218
+ const safeHash = assertGitHash(hash);
1219
+ logger.info(`执行Cherry-pick操作: hash=${safeHash}`);
1145
1220
 
1146
1221
  // 执行git cherry-pick命令
1147
- await execGitCommand(['cherry-pick', hash]);
1222
+ await execGitCommand(['cherry-pick', safeHash]);
1148
1223
 
1149
1224
  res.json({
1150
1225
  success: true,
1151
- message: `已成功Cherry-pick提交 ${hash}`
1226
+ message: `已成功Cherry-pick提交 ${safeHash}`
1152
1227
  });
1153
1228
  } catch (error) {
1154
1229
  logger.error('Cherry-pick提交失败:', error);
1155
- res.status(500).json({
1230
+ res.status(error?.statusCode || 500).json({
1156
1231
  success: false,
1157
1232
  error: `Cherry-pick提交失败: ${error.message}`
1158
1233
  });
@@ -1171,18 +1246,19 @@ export function registerGitOpsRoutes({
1171
1246
  });
1172
1247
  }
1173
1248
 
1174
- logger.info(`执行重置到指定提交操作: hash=${hash}`);
1249
+ const safeHash = assertGitHash(hash);
1250
+ logger.info(`执行重置到指定提交操作: hash=${safeHash}`);
1175
1251
 
1176
1252
  // 执行git reset --hard命令
1177
- await execGitCommand(['reset', '--hard', hash]);
1253
+ await execGitCommand(['reset', '--hard', safeHash]);
1178
1254
 
1179
1255
  res.json({
1180
1256
  success: true,
1181
- message: `已成功重置到提交 ${hash}`
1257
+ message: `已成功重置到提交 ${safeHash}`
1182
1258
  });
1183
1259
  } catch (error) {
1184
1260
  logger.error('重置到指定提交失败:', error);
1185
- res.status(500).json({
1261
+ res.status(error?.statusCode || 500).json({
1186
1262
  success: false,
1187
1263
  error: `重置到指定提交失败: ${error.message}`
1188
1264
  });
@@ -1196,13 +1272,14 @@ export function registerGitOpsRoutes({
1196
1272
  if (!hash) {
1197
1273
  return res.status(400).json({ success: false, error: '缺少提交哈希参数' });
1198
1274
  }
1199
- const { stdout } = await execGitCommand(['show', hash]);
1275
+ const safeHash = assertGitHash(hash);
1276
+ const { stdout } = await execGitCommand(['show', safeHash]);
1200
1277
  // 限制最大 200KB 防止内容过大
1201
1278
  const MAX = 200 * 1024;
1202
1279
  const content = stdout.length > MAX ? stdout.slice(0, MAX) + '\n\n[内容过大,已截断]' : stdout;
1203
1280
  res.json({ success: true, content });
1204
1281
  } catch (error) {
1205
- res.status(500).json({ success: false, error: `获取提交内容失败: ${error.message}` });
1282
+ res.status(error?.statusCode || 500).json({ success: false, error: `获取提交内容失败: ${error.message}` });
1206
1283
  }
1207
1284
  });
1208
1285
 
@@ -1311,11 +1388,13 @@ export function registerGitOpsRoutes({
1311
1388
  if (!url || !url.trim()) {
1312
1389
  return res.json({ success: false, error: '远程仓库地址不能为空' });
1313
1390
  }
1314
- await execGitCommand(['remote', 'add', name, url.trim()]);
1391
+ const safeName = assertGitRef(name, '远程仓库名');
1392
+ const safeUrl = assertGitRemoteUrl(url);
1393
+ await execGitCommand(['remote', 'add', safeName, safeUrl]);
1315
1394
  res.json({ success: true });
1316
1395
  } catch (error) {
1317
1396
  logger.error('添加远程仓库失败:', error);
1318
- res.json({ success: false, error: error.message || '添加远程仓库失败' });
1397
+ res.status(error?.statusCode || 200).json({ success: false, error: error.message || '添加远程仓库失败' });
1319
1398
  }
1320
1399
  });
1321
1400
 
@@ -162,9 +162,19 @@ export function registerNpmRoutes({
162
162
  res.setHeader('Connection', 'keep-alive')
163
163
  res.flushHeaders?.()
164
164
 
165
+ // 客户端断开后 res.write 会抛 EPIPE。这里必须吞掉:
166
+ // 异常若从 child.stdout 的 'data' 监听器冒泡出去,会变成 uncaughtException,
167
+ // 命中 index.js 的 fatalExit → 整个 GUI 服务退出,而不仅仅是这一个请求失败。
168
+ let clientClosed = false
165
169
  const send = (obj) => {
166
- res.write(JSON.stringify(obj) + '\n')
170
+ if (clientClosed) return
171
+ try {
172
+ res.write(JSON.stringify(obj) + '\n')
173
+ } catch {
174
+ // res 已关闭,忽略
175
+ }
167
176
  }
177
+ const finish = () => { try { res.end() } catch { /* ignore */ } }
168
178
 
169
179
  // Windows 上 npm install -g 通常不需要管理员;*nix 需要 sudo。
170
180
  // 用 sudo -n(非交互式)检测是否可免密,失败时让用户用 sudo 重启 GUI
@@ -188,7 +198,7 @@ export function registerNpmRoutes({
188
198
  } catch (err) {
189
199
  send({ type: 'error', message: `启动升级进程失败: ${err.message}` })
190
200
  send({ type: 'done', code: -1 })
191
- res.end()
201
+ finish()
192
202
  cleanup()
193
203
  return
194
204
  }
@@ -215,7 +225,28 @@ export function registerNpmRoutes({
215
225
  send({ type: 'error', message: `\n升级失败,退出码 ${code}${hint ? ' ' + hint : ''}\n` })
216
226
  }
217
227
  send({ type: 'done', code })
218
- res.end()
228
+ finish()
229
+ cleanup()
230
+ })
231
+
232
+ // 客户端断连 → 主动 kill,防止孤儿 npm 进程;同时必须 cleanup(),
233
+ // 否则 activeUpgrade 一直非 null,后续升级请求会永久返回 409。
234
+ req.on('close', () => {
235
+ if (clientClosed) return
236
+ clientClosed = true
237
+ if (child && !child.killed) {
238
+ try {
239
+ child.kill('SIGTERM')
240
+ setTimeout(() => {
241
+ if (!child.killed) {
242
+ try { child.kill('SIGKILL') } catch { /* ignore */ }
243
+ }
244
+ }, 2000)
245
+ } catch (e) {
246
+ logger.warn('[app-upgrade] 客户端断连后 kill 子进程失败:', e?.message || e)
247
+ }
248
+ }
249
+ finish()
219
250
  cleanup()
220
251
  })
221
252
  })
@@ -1092,19 +1123,14 @@ export function registerNpmRoutes({
1092
1123
  logger.error('打开终端失败:', err);
1093
1124
  }
1094
1125
 
1126
+ // 只响应一次。原先这里连续调用了两次 res.json() → ERR_HTTP_HEADERS_SENT,
1127
+ // 且第二次还把未经校验的原始 packagePath 直接回给了前端。
1095
1128
  res.json({
1096
1129
  success: true,
1097
1130
  message: `已在新终端中执行: ${scriptName}`,
1098
1131
  command: npmCommand,
1099
1132
  path: resolvedPkgPath
1100
1133
  });
1101
-
1102
- res.json({
1103
- success: true,
1104
- message: `已在新终端中执行: ${scriptName}`,
1105
- command: npmCommand,
1106
- path: packagePath
1107
- });
1108
1134
  } catch (error) {
1109
1135
  logger.error('执行npm脚本失败:', error);
1110
1136
  res.status(500).json({