zen-gitsync 2.13.29 → 2.13.32

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 (83) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -1
  3. package/src/cli/cleanup.js +179 -0
  4. package/src/cli/cleanup.test.js +269 -0
  5. package/src/cli/customCommand.js +94 -0
  6. package/src/cli/customCommand.test.js +143 -0
  7. package/src/cli/ui.js +59 -0
  8. package/src/cli/ui.test.js +115 -0
  9. package/src/config.js +62 -17
  10. package/src/gitCommit.js +73 -25
  11. package/src/ui/public/assets/AppVersionBadge-Ci_-H7TK.js +2 -0
  12. package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +1 -0
  13. package/src/ui/public/assets/BranchSelector-BIGlKQme.js +1 -0
  14. package/src/ui/public/assets/CommandConsole-DUu75Lvw.js +17 -0
  15. package/src/ui/public/assets/CommandConsole-Da3j09oc.css +1 -0
  16. package/src/ui/public/assets/CommitForm-asrs-955.js +6 -0
  17. package/src/ui/public/assets/CustomCommandManager-BBnTU2pp.js +1 -0
  18. package/src/ui/public/assets/EditorView-BYuHlMbn.js +0 -0
  19. package/src/ui/public/assets/{EditorView-Do_aRkqm.css → EditorView-BcZ8ToAk.css} +1 -1
  20. package/src/ui/public/assets/{FileDiffViewer-ASPmnTdG.css → FileDiffViewer-CLvy7mIG.css} +1 -1
  21. package/src/ui/public/assets/FileDiffViewer-vOvrYF89.js +13 -0
  22. package/src/ui/public/assets/{FlowExecutionViewer-ADwYz1XL.js → FlowExecutionViewer-DgQs18Ym.js} +1 -1
  23. package/src/ui/public/assets/FlowOrchestrationWorkspace-CEM_0XAJ.js +21 -0
  24. package/src/ui/public/assets/GitStatus-DedZ2a63.js +6 -0
  25. package/src/ui/public/assets/ImagePreview-XjBK0T26.js +1 -0
  26. package/src/ui/public/assets/LogList-CUn9TvU1.js +7 -0
  27. package/src/ui/public/assets/ProjectStartupButton-DWfKVcJo.js +1 -0
  28. package/src/ui/public/assets/RemoteRepoCard-BVRJ8eAD.js +1 -0
  29. package/src/ui/public/assets/ResetToRemoteButton-CDhmy9Qj.js +1 -0
  30. package/src/ui/public/assets/SourceMapView-CUq_XXYS.js +3 -0
  31. package/src/ui/public/assets/SourceMapView-DjYKJZLj.css +1 -0
  32. package/src/ui/public/assets/TemplateManager-CogQCagT.js +1 -0
  33. package/src/ui/public/assets/UserInputNode-BcIaDC-e.js +1 -0
  34. package/src/ui/public/assets/WorkbenchView-CGclOlIQ.js +12 -0
  35. package/src/ui/public/assets/WorkbenchView-ir769eTr.css +1 -0
  36. package/src/ui/public/assets/{_plugin-vue_export-helper-DKIb-p1R.js → _plugin-vue_export-helper-BWJlNHzL.js} +3 -3
  37. package/src/ui/public/assets/element-plus-CRTdimvR.js +24 -0
  38. package/src/ui/public/assets/{flow-mindmap-mZisamFd.js → flow-mindmap-oBAz3_QK.js} +4 -4
  39. package/src/ui/public/assets/index-BkpNzYyv.js +7 -0
  40. package/src/ui/public/assets/index-CNhCnXkD.css +1 -0
  41. package/src/ui/public/assets/vendor-BxotT5qN.js +93 -0
  42. package/src/ui/public/assets/vue-flow-Bx03URoq.js +39 -0
  43. package/src/ui/public/index.html +6 -6
  44. package/src/ui/server/index.js +1 -0
  45. package/src/ui/server/routes/npm.js +161 -8
  46. package/src/ui/server/utils/instanceRegistry.js +11 -0
  47. package/src/ui/server/utils/startServerOnAvailablePort.js +13 -0
  48. package/src/utils/index.js +42 -10
  49. package/src/utils/parseCwdArg.test.js +89 -0
  50. package/src/utils/utils.test.js +200 -0
  51. package/src/ui/public/assets/AppVersionBadge-CTUXSSX1.css +0 -1
  52. package/src/ui/public/assets/AppVersionBadge-Dnl4p9oF.js +0 -2
  53. package/src/ui/public/assets/BranchSelector-C64gm1XB.js +0 -1
  54. package/src/ui/public/assets/CommandConsole-60KxfUE3.css +0 -1
  55. package/src/ui/public/assets/CommandConsole-CbufZ-aa.js +0 -17
  56. package/src/ui/public/assets/CommitForm-CVPUXS7N.js +0 -6
  57. package/src/ui/public/assets/CustomCommandManager-DYXfu8Gr.js +0 -1
  58. package/src/ui/public/assets/EditorView-DaLTdDZK.js +0 -0
  59. package/src/ui/public/assets/FileDiffViewer-BvL-9S74.js +0 -13
  60. package/src/ui/public/assets/FlowOrchestrationWorkspace-DQhf9Uyr.js +0 -21
  61. package/src/ui/public/assets/GitStatus-CjNzYRV2.js +0 -6
  62. package/src/ui/public/assets/ImagePreview-Bo8Y4HdH.js +0 -1
  63. package/src/ui/public/assets/LogList-vdQwp7e5.js +0 -7
  64. package/src/ui/public/assets/ProjectStartupButton-CIkYLyt7.js +0 -1
  65. package/src/ui/public/assets/RemoteRepoCard-CpyLlD_n.js +0 -1
  66. package/src/ui/public/assets/ResetToRemoteButton-BElORAbP.js +0 -1
  67. package/src/ui/public/assets/SourceMapView-BS35GVi7.js +0 -3
  68. package/src/ui/public/assets/SourceMapView-DTsSdcgG.css +0 -1
  69. package/src/ui/public/assets/TemplateManager-B5s2lBKa.js +0 -1
  70. package/src/ui/public/assets/UserInputNode-CKoZYrQX.js +0 -1
  71. package/src/ui/public/assets/WorkbenchView-DKDEzEIV.js +0 -12
  72. package/src/ui/public/assets/WorkbenchView-MepwSID3.css +0 -1
  73. package/src/ui/public/assets/__vite-optional-peer-dep_katex_markstream-vue-Bg9-FXxr.js +0 -1
  74. package/src/ui/public/assets/__vite-optional-peer-dep_mermaid_markstream-vue-cPZl55sA.js +0 -1
  75. package/src/ui/public/assets/__vite-optional-peer-dep_stream-markdown_markstream-vue-Dhgp-lRL.js +0 -1
  76. package/src/ui/public/assets/__vite-optional-peer-dep_stream-monaco_markstream-vue-Cr1d6De7.js +0 -1
  77. package/src/ui/public/assets/d2_markstream-vue-xMoF3M5G.js +0 -1
  78. package/src/ui/public/assets/element-plus-rcpA4EGF.js +0 -26
  79. package/src/ui/public/assets/index-BYAxMGne.css +0 -1
  80. package/src/ui/public/assets/index-DJIlYzzK.js +0 -7
  81. package/src/ui/public/assets/mhchem_markstream-vue-tp86PqxG.js +0 -1
  82. package/src/ui/public/assets/vendor-DoD2dmIW.js +0 -226
  83. package/src/ui/public/assets/vue-flow-CfamgGcL.js +0 -39
package/README.md CHANGED
@@ -288,7 +288,7 @@ Prompt presets and tasks are persisted to `~/.zen-gitsync/prompts.json` and `~/.
288
288
 
289
289
  ### Self-Upgrade
290
290
 
291
- The footer version chip in the GUI checks npm for newer releases once per session. When an update is available, a **Upgrade** button appears next to the version; clicking it streams `npm install -g zen-gitsync` output into a modal dialog. On success, the dialog switches to a "Restart and reload now" CTA that calls `POST /api/app-restart` (which gracefully exits the Node process your launcher / desktop shell brings it back up) and then reloads the browser tab so the new SPA bundle takes effect. The footer version also updates instantly to the new number so you can see the bump even before restarting.
291
+ The footer version chip in the GUI checks npm for newer releases once per session. When an update is available, a **Upgrade** button appears next to the version; clicking it streams `npm install -g zen-gitsync` output into a modal dialog. On success, the dialog switches to a "Restart and reload now" CTA. Clicking it calls `POST /api/app-restart`, which **self-respawns a new Node process** (no external launcher / desktop shell is required), waits for the new process to bind its port, streams that port back to the browser over NDJSON, and gracefully exits the old process. The browser then **redirects** to the new port (preserving the current path, query, and hash) so the upgraded backend serves the next request. The footer version also updates instantly to the new number so you can see the bump even before restarting. If the child process fails to come up within 8s, the old process is preserved and an error toast is shown — your session stays connected.
292
292
 
293
293
  On macOS / Linux, the global install is run under `sudo -n` (non-interactive); if sudo can't authenticate non-interactively, re-launch the GUI with admin rights and try again.
294
294
 
@@ -690,7 +690,7 @@ Activity Bar 第四个视图,用于在当前仓库上批量调度 Claude:定
690
690
 
691
691
  ### 自升级
692
692
 
693
- GUI 底栏版本号每个会话会向 npm 查询一次最新版本。检测到更新时版本旁会出现 **升级** 按钮,点击后在弹窗里实时回传 `npm install -g zen-gitsync` 的输出;升级成功后弹窗会切换为「**立即重启并刷新**」主 CTA,调用 `POST /api/app-restart` Node 进程优雅退出(外层 launcher / desktop 壳会自动拉起新版本),再 reload 当前 tab 让新 SPA 资源生效。同时底栏版本号会立刻刷新到新版本号,重启前就能看到。
693
+ GUI 底栏版本号每个会话会向 npm 查询一次最新版本。检测到更新时版本旁会出现 **升级** 按钮,点击后在弹窗里实时回传 `npm install -g zen-gitsync` 的输出;升级成功后弹窗会切换为「**立即重启并刷新**」主 CTA。点击后调用 `POST /api/app-restart`,后端**自行 spawn Node 进程**(不依赖任何外层 launcher / 桌面壳),通过 NDJSON 流把新进程端口推回前端,旧进程再优雅退出;浏览器**重定向**到新端口(保留当前 path、query、hash)由新后端服务后续请求。同时底栏版本号会立刻刷新到新版本号,重启前就能看到。若子进程 8 秒内未就绪,旧进程不退出并弹错误提示,您的会话保持连接。
694
694
 
695
695
  > macOS / Linux 上全局安装需要 sudo,前端会用 `sudo -n` 非交互式尝试;如非免密 sudo,请以管理员权限重启 GUI 后再试。
696
696
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-gitsync",
3
- "version": "2.13.29",
3
+ "version": "2.13.32",
4
4
  "description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -55,7 +55,9 @@
55
55
  "src/utils/**",
56
56
  "src/gitCommit.js",
57
57
  "src/config.js",
58
+ "src/cli/**",
58
59
  "scripts/**",
60
+ "!scripts/archive/**",
59
61
  "package.json",
60
62
  "index.js"
61
63
  ],
@@ -0,0 +1,179 @@
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
+ // 长跑 CLI 任务的资源清理工具(SIGINT 时统一 drain 定时器 + 子进程)。
16
+ // 抽到独立模块是为了便于单元测试 + 跨多个 CLI 入口(gitCommit.js /
17
+ // future scheduled 命令面板)复用。
18
+ //
19
+ // 用法:
20
+ // import { registerCleanup, trackChild, setupSigintHandler } from './cli/cleanup.js'
21
+ // registerCleanup('mytimer', () => clearInterval(t))
22
+ // const child = trackChild(exec('npm run build'))
23
+ // setupSigintHandler() // 只调一次,SIGINT 时按顺序 drain
24
+ //
25
+ // 设计原则:
26
+ // - 单次注册:setupSigintHandler 内部用 sigintRegistered 守防止重复挂监听
27
+ // - 幂等触发:多次 SIGINT 只生效第一次,避免 drain 中再次进入 cleanupTasks
28
+ // - 优雅退出:子进程先 SIGTERM 给 500ms 窗口,SIGKILL 兜底
29
+ // - 错误隔离:cleanup fn 抛错不影响后续 fn 与 killChildren
30
+
31
+ import process from 'node:process'
32
+
33
+ /**
34
+ * @typedef {Object} CleanupRegistry
35
+ * @property {() => Promise<void> | void} [fn] - cleanup 函数
36
+ */
37
+
38
+ /** @type {Map<string, () => void | Promise<void>>} */
39
+ const cleanupTasks = new Map()
40
+
41
+ /** @type {Set<import('node:child_process').ChildProcess>} */
42
+ const activeChildren = new Set()
43
+
44
+ let sigintRegistered = false
45
+ let sigintFired = false
46
+
47
+ /**
48
+ * 注册一个 cleanup 任务。同名注册会覆盖前一次(常用于"最近一次 setTimeout"
49
+ * 这种语义:递归 commit 时,新 timer 替换旧 timer,旧 timer 自然过期)。
50
+ *
51
+ * @param {string} name - 任务唯一标识
52
+ * @param {() => void | Promise<void>} fn - 清理函数,可异步
53
+ */
54
+ export function registerCleanup(name, fn) {
55
+ if (typeof name !== 'string' || !name) {
56
+ throw new TypeError('registerCleanup: name 必须是非空字符串')
57
+ }
58
+ if (typeof fn !== 'function') {
59
+ throw new TypeError('registerCleanup: fn 必须是函数')
60
+ }
61
+ cleanupTasks.set(name, fn)
62
+ }
63
+
64
+ /**
65
+ * 注销一个已注册的 cleanup 任务。
66
+ *
67
+ * @param {string} name
68
+ */
69
+ export function unregisterCleanup(name) {
70
+ cleanupTasks.delete(name)
71
+ }
72
+
73
+ /**
74
+ * 清空所有已注册任务(测试 / 重置场景用)。
75
+ */
76
+ export function clearAllCleanup() {
77
+ cleanupTasks.clear()
78
+ }
79
+
80
+ /**
81
+ * 跟踪 child_process 子进程,SIGINT 时统一 kill。
82
+ * 子进程 exit 时自动从集合移除,避免内存泄漏。
83
+ *
84
+ * @template {import('node:child_process').ChildProcess} T
85
+ * @param {T} child
86
+ * @returns {T} 原 child(便于链式调用)
87
+ */
88
+ export function trackChild(child) {
89
+ if (!child || typeof child.kill !== 'function') return child
90
+ activeChildren.add(child)
91
+ child.once('exit', () => activeChildren.delete(child))
92
+ return child
93
+ }
94
+
95
+ /**
96
+ * 杀掉所有跟踪中的子进程。先 SIGTERM 给 graceMs 优雅退出,再 SIGKILL 兜底。
97
+ *
98
+ * @param {number} [graceMs=500] - SIGTERM 后等多久升级到 SIGKILL
99
+ */
100
+ export async function killAllTrackedChildren(graceMs = 500) {
101
+ const children = [...activeChildren]
102
+ if (children.length === 0) return { terminated: 0 }
103
+ for (const child of children) {
104
+ try { child.kill('SIGTERM') } catch (_) {}
105
+ }
106
+ await new Promise((r) => setTimeout(r, graceMs))
107
+ for (const child of children) {
108
+ try { child.kill('SIGKILL') } catch (_) {}
109
+ }
110
+ return { terminated: children.length }
111
+ }
112
+
113
+ /**
114
+ * 同步执行所有注册任务(支持 async fn)。任何 fn 抛错都被吞掉,不影响后续。
115
+ */
116
+ export async function runCleanupTasks() {
117
+ const tasks = [...cleanupTasks.values()]
118
+ cleanupTasks.clear()
119
+ for (const fn of tasks) {
120
+ try {
121
+ await fn()
122
+ } catch (_) {
123
+ // 隔离错误 — 单个 cleanup 失败不能阻断其它资源释放
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 当前跟踪中的子进程数(测试 / 调试用)。
130
+ */
131
+ export function getTrackedChildCount() {
132
+ return activeChildren.size
133
+ }
134
+
135
+ /**
136
+ * 全程序注册一次 SIGINT 处理器。
137
+ * 顺序:logUpdate.clear → runCleanupTasks → killAllTrackedChildren → exit 130。
138
+ * 多次调用安全(幂等 — sigintRegistered 守卫)。
139
+ *
140
+ * @param {object} [options]
141
+ * @param {() => void} [options.onBeforeCleanup] - 清理前钩子(用于 logUpdate.clear 等)
142
+ * @param {() => void} [options.onAfterCleanup] - 清理后钩子
143
+ * @returns {() => void} dispose — 注销函数(测试场景用来清理)
144
+ */
145
+ export function setupSigintHandler(options = {}) {
146
+ if (sigintRegistered) return () => {}
147
+ sigintRegistered = true
148
+ const handler = async () => {
149
+ if (sigintFired) return
150
+ sigintFired = true
151
+ try {
152
+ try { options.onBeforeCleanup?.() } catch (_) {}
153
+ await runCleanupTasks()
154
+ await killAllTrackedChildren()
155
+ try { options.onAfterCleanup?.() } catch (_) {}
156
+ } finally {
157
+ // 128 + SIGINT(2) = 130,符合 shell 惯例
158
+ process.exit(130)
159
+ }
160
+ }
161
+ process.on('SIGINT', handler)
162
+ // 返回 dispose 用于测试清理 — removeListener + 重置标志
163
+ return () => {
164
+ process.removeListener('SIGINT', handler)
165
+ sigintRegistered = false
166
+ sigintFired = false
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 重置内部状态 — 仅用于测试场景,生产代码不要调。
172
+ * 注意:不主动移除 SIGINT listener(由调用方通过 setupSigintHandler 返回的 dispose 处理)。
173
+ */
174
+ export function _resetForTests() {
175
+ cleanupTasks.clear()
176
+ activeChildren.clear()
177
+ sigintRegistered = false
178
+ sigintFired = false
179
+ }
@@ -0,0 +1,269 @@
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
+ // src/cli/cleanup.js 单元测试(node:test 内置)。
16
+ // 覆盖 registerCleanup / trackChild / runCleanupTasks / killAllTrackedChildren
17
+ // 的核心契约:幂等、错误隔离、覆盖语义、SIGINT 触发清理。
18
+ import { test, afterEach } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import { EventEmitter } from 'node:events'
21
+ import {
22
+ registerCleanup, unregisterCleanup, clearAllCleanup,
23
+ trackChild, killAllTrackedChildren, runCleanupTasks,
24
+ getTrackedChildCount, setupSigintHandler, _resetForTests,
25
+ } from './cleanup.js'
26
+
27
+ // 每个 case 跑完清理一次,避免 SIGINT 注册 / 子进程跟踪跨用例污染。
28
+ // 保存当前 case 注册的 dispose,在 afterEach 调用,确保 process 的 SIGINT
29
+ // listener 列表不会越积越多,跨 test 的 handler 不会误触发。
30
+ const disposers = []
31
+ afterEach(() => {
32
+ while (disposers.length) {
33
+ try { disposers.pop()() } catch (_) {}
34
+ }
35
+ _resetForTests()
36
+ })
37
+ function trackDispose(dispose) { disposers.push(dispose); return dispose }
38
+
39
+ // ========== registerCleanup ==========
40
+
41
+ test('registerCleanup: 注册同步 fn 并触发 runCleanupTasks', async () => {
42
+ let called = 0
43
+ registerCleanup('sync', () => { called++ })
44
+ await runCleanupTasks()
45
+ assert.equal(called, 1)
46
+ })
47
+
48
+ test('registerCleanup: 注册异步 fn 并 await 完成', async () => {
49
+ let resolved = false
50
+ registerCleanup('async', async () => {
51
+ await new Promise((r) => setTimeout(r, 10))
52
+ resolved = true
53
+ })
54
+ await runCleanupTasks()
55
+ assert.equal(resolved, true)
56
+ })
57
+
58
+ test('registerCleanup: 同名注册覆盖前一次', async () => {
59
+ let first = 0
60
+ let second = 0
61
+ registerCleanup('overlap', () => { first++ })
62
+ registerCleanup('overlap', () => { second++ })
63
+ await runCleanupTasks()
64
+ assert.equal(first, 0, '首次注册应被覆盖')
65
+ assert.equal(second, 1)
66
+ })
67
+
68
+ test('registerCleanup: 多个任务按注册顺序串行执行', async () => {
69
+ const order = []
70
+ registerCleanup('a', async () => {
71
+ await new Promise((r) => setTimeout(r, 5))
72
+ order.push('a')
73
+ })
74
+ registerCleanup('b', () => { order.push('b') })
75
+ registerCleanup('c', () => { order.push('c') })
76
+ await runCleanupTasks()
77
+ assert.deepEqual(order, ['a', 'b', 'c'])
78
+ })
79
+
80
+ test('registerCleanup: 单个 fn 抛错不影响后续', async () => {
81
+ let afterError = false
82
+ registerCleanup('bad', () => { throw new Error('boom') })
83
+ registerCleanup('good', () => { afterError = true })
84
+ await runCleanupTasks()
85
+ assert.equal(afterError, true)
86
+ })
87
+
88
+ test('registerCleanup: 入参校验', () => {
89
+ assert.throws(() => registerCleanup('', () => {}), /name/)
90
+ assert.throws(() => registerCleanup(null, () => {}), /name/)
91
+ assert.throws(() => registerCleanup('x', null), /fn/)
92
+ assert.throws(() => registerCleanup('x', 'not fn'), /fn/)
93
+ })
94
+
95
+ test('registerCleanup: runCleanupTasks 后任务被清空(单次执行语义)', async () => {
96
+ let called = 0
97
+ registerCleanup('once', () => { called++ })
98
+ await runCleanupTasks()
99
+ await runCleanupTasks()
100
+ assert.equal(called, 1, '第二次 run 不应再触发已清理任务')
101
+ })
102
+
103
+ test('unregisterCleanup: 注销后任务不再触发', async () => {
104
+ let called = 0
105
+ registerCleanup('temp', () => { called++ })
106
+ unregisterCleanup('temp')
107
+ await runCleanupTasks()
108
+ assert.equal(called, 0)
109
+ })
110
+
111
+ // ========== trackChild ==========
112
+
113
+ test('trackChild: 自动跟踪 child 并在 exit 时移除', () => {
114
+ const fake = new EventEmitter()
115
+ fake.kill = () => true
116
+ trackChild(fake)
117
+ assert.equal(getTrackedChildCount(), 1)
118
+ fake.emit('exit', 0, null)
119
+ assert.equal(getTrackedChildCount(), 0, 'exit 后应自动移除')
120
+ })
121
+
122
+ test('trackChild: null / 非 child 入参安全 passthrough', () => {
123
+ // 防御:exec() 在某些平台可能返回 undefined
124
+ assert.equal(trackChild(null), null)
125
+ assert.equal(trackChild(undefined), undefined)
126
+ assert.equal(getTrackedChildCount(), 0)
127
+ })
128
+
129
+ test('trackChild: 链式调用保留原 child 引用', () => {
130
+ const fake = new EventEmitter()
131
+ fake.kill = () => true
132
+ const result = trackChild(fake)
133
+ assert.equal(result, fake)
134
+ })
135
+
136
+ // ========== killAllTrackedChildren ==========
137
+
138
+ test('killAllTrackedChildren: 空集合快速返回', async () => {
139
+ const result = await killAllTrackedChildren(10)
140
+ assert.deepEqual(result, { terminated: 0 })
141
+ })
142
+
143
+ test('killAllTrackedChildren: 调用所有 child 的 kill', async () => {
144
+ const kills = []
145
+ const fake1 = new EventEmitter(); fake1.kill = (sig) => { kills.push(sig); fake1.emit('exit') }
146
+ const fake2 = new EventEmitter(); fake2.kill = (sig) => { kills.push(sig); fake2.emit('exit') }
147
+ trackChild(fake1); trackChild(fake2)
148
+ const result = await killAllTrackedChildren(10) // 短 graceMs 让测试快
149
+ assert.equal(result.terminated, 2)
150
+ // 每个 child 应被 SIGTERM,exit 后 SIGKILL 不再调用(已从集合移除)
151
+ assert.equal(kills.filter(s => s === 'SIGTERM').length, 2)
152
+ })
153
+
154
+ test('killAllTrackedChildren: child.kill 抛错不阻断其它', async () => {
155
+ const bad = new EventEmitter(); bad.kill = () => { throw new Error('EPERM') }
156
+ const good = new EventEmitter(); good.kill = () => good.emit('exit')
157
+ trackChild(bad); trackChild(good)
158
+ // 不应抛 — 错误隔离
159
+ const result = await killAllTrackedChildren(10)
160
+ assert.equal(result.terminated, 2)
161
+ })
162
+
163
+ // ========== runCleanupTasks 与 child 协同 ==========
164
+
165
+ test('runCleanupTasks 顺序:先 cleanup 再 kill children(cleanup 可主动撤销子进程)', async () => {
166
+ const fake = new EventEmitter()
167
+ let killCount = 0
168
+ fake.kill = () => { killCount++; fake.emit('exit') }
169
+ trackChild(fake)
170
+ let cleanupRanFirst = null
171
+ registerCleanup('kill-it', () => {
172
+ if (killCount === 0) cleanupRanFirst = true
173
+ fake.kill('SIGTERM')
174
+ })
175
+ // 注:这里手动模拟顺序 — 实际 setupSigintHandler 中 runCleanupTasks 在 kill 之前
176
+ await runCleanupTasks()
177
+ assert.equal(cleanupRanFirst, true, 'cleanup 应在 kill 之前跑')
178
+ // killAllTrackedChildren 之后:
179
+ await killAllTrackedChildren(10)
180
+ // 此时 fake 已 exit,集合为空,不会再被 kill
181
+ })
182
+
183
+ // ========== setupSigintHandler 行为契约(不真触发 SIGINT) ==========
184
+
185
+ test('setupSigintHandler: 多次调用幂等(只注册一次)', () => {
186
+ // 通过 listenerCount 验证
187
+ const before = process.listenerCount('SIGINT')
188
+ trackDispose(setupSigintHandler())
189
+ trackDispose(setupSigintHandler())
190
+ trackDispose(setupSigintHandler())
191
+ const after = process.listenerCount('SIGINT')
192
+ assert.equal(after - before, 1, '多次 setupSigintHandler 只应增加 1 个 listener')
193
+ })
194
+
195
+ // 辅助:触发 SIGINT 后等待 async handler 跑完。
196
+ // 直接 await emit 不行,因为 process.emit 对 async listener 不等待。
197
+ // 关键是:把 process.exit stub 成"只记录、不退出",这样 handler 跑完后
198
+ // promise 正常 resolve,不会变成 unhandled rejection。
199
+ async function fireSigintAndAwait() {
200
+ process.emit('SIGINT')
201
+ // 给 async handler 几个 microtask + macrotask 跑完
202
+ await new Promise((r) => setImmediate(r))
203
+ await new Promise((r) => setImmediate(r))
204
+ await new Promise((r) => setImmediate(r))
205
+ }
206
+
207
+ test('setupSigintHandler: SIGINT 触发 cleanup → exit(集成测试)', async () => {
208
+ let cleaned = false
209
+ registerCleanup('sigint-test', () => { cleaned = true })
210
+ trackDispose(setupSigintHandler({ onBeforeCleanup: () => {}, onAfterCleanup: () => {} }))
211
+ const originalExit = process.exit
212
+ let exitCode = null
213
+ process.exit = (code) => { exitCode = code /* 不真退出,让 handler 继续跑 */ }
214
+ try {
215
+ await fireSigintAndAwait()
216
+ assert.equal(cleaned, true, 'SIGINT 应触发 cleanup')
217
+ assert.equal(exitCode, 130, 'SIGINT 应以 exit code 130 退出(128 + 2)')
218
+ } finally {
219
+ process.exit = originalExit
220
+ }
221
+ })
222
+
223
+ test('setupSigintHandler: SIGINT 二次触发幂等', async () => {
224
+ let cleaned = 0
225
+ registerCleanup('idempotent', () => { cleaned++ })
226
+ trackDispose(setupSigintHandler())
227
+ const originalExit = process.exit
228
+ process.exit = () => { /* 不真退出 */ }
229
+ try {
230
+ await fireSigintAndAwait()
231
+ await fireSigintAndAwait()
232
+ await fireSigintAndAwait()
233
+ assert.equal(cleaned, 1, 'cleanup 应只跑一次')
234
+ } finally {
235
+ process.exit = originalExit
236
+ }
237
+ })
238
+
239
+ test('setupSigintHandler: cleanup 抛错不阻断 exit', async () => {
240
+ registerCleanup('bad', () => { throw new Error('boom') })
241
+ trackDispose(setupSigintHandler())
242
+ const originalExit = process.exit
243
+ let exited = false
244
+ process.exit = () => { exited = true }
245
+ try {
246
+ await fireSigintAndAwait()
247
+ assert.equal(exited, true, '即使 cleanup 抛错也应调用 exit')
248
+ } finally {
249
+ process.exit = originalExit
250
+ }
251
+ })
252
+
253
+ test('setupSigintHandler: onBeforeCleanup / onAfterCleanup 钩子被调用', async () => {
254
+ let before = false
255
+ let after = false
256
+ trackDispose(setupSigintHandler({
257
+ onBeforeCleanup: () => { before = true },
258
+ onAfterCleanup: () => { after = true },
259
+ }))
260
+ const originalExit = process.exit
261
+ process.exit = () => { /* 不真退出 */ }
262
+ try {
263
+ await fireSigintAndAwait()
264
+ assert.equal(before, true)
265
+ assert.equal(after, true)
266
+ } finally {
267
+ process.exit = originalExit
268
+ }
269
+ })
@@ -0,0 +1,94 @@
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
+ // --cmd= 自定义命令的输入校验与执行模式选择。
16
+ // 抽到独立模块是为了集中 SEC-CLI-1 的对策,便于单测和复用。
17
+ //
18
+ // 设计原则:
19
+ // 1. **不破坏向后兼容**:`g:test-cmd="echo hello | wc -l"` 这种合法用法必须能跑
20
+ // 2. **检测而非阻止**:本地 CLI 工具,用户明确输入,主要拦截意外/恶意输入
21
+ // 3. **严格模式可显式启用**:--cmd-strict 走 execFile 拆 argv,无 shell 特性
22
+
23
+ /**
24
+ * 自定义命令的最大允许长度 — 防止误粘贴巨型 base64 等
25
+ */
26
+ export const MAX_CUSTOM_CMD_LENGTH = 1000
27
+
28
+ /**
29
+ * 检测输入校验结果
30
+ * @typedef {Object} CmdValidation
31
+ * @property {boolean} ok - 是否可继续执行
32
+ * @property {string[]} warnings - 风险提示(不阻止,仅 console.warn)
33
+ * @property {string} [rejectReason] - 不可执行的硬性原因(null/undefined 表示允许)
34
+ */
35
+
36
+ /**
37
+ * 校验 --cmd= 的输入
38
+ *
39
+ * 校验项:
40
+ * - 必须是非空字符串
41
+ * - 不能以 - 开头(看起来像选项,容易被 exec 误解析或 git 误接收)
42
+ * - 长度不超过 MAX_CUSTOM_CMD_LENGTH
43
+ * - 包含明显危险模式时打 warning(不阻止):rm -rf / · fork bomb · chmod -R 777 /
44
+ *
45
+ * @param {unknown} cmd - 来自 argv 的命令字符串
46
+ * @returns {CmdValidation}
47
+ */
48
+ export function validateCustomCommand(cmd) {
49
+ const warnings = []
50
+ if (typeof cmd !== 'string') {
51
+ return { ok: false, warnings, rejectReason: '命令必须是字符串' }
52
+ }
53
+ const trimmed = cmd.trim()
54
+ if (trimmed.length === 0) {
55
+ return { ok: false, warnings, rejectReason: '命令不能为空' }
56
+ }
57
+ if (cmd.length > MAX_CUSTOM_CMD_LENGTH) {
58
+ return {
59
+ ok: false,
60
+ warnings,
61
+ rejectReason: `命令过长(${cmd.length} > ${MAX_CUSTOM_CMD_LENGTH} 字符),可能是误粘贴`
62
+ }
63
+ }
64
+ // 以 - 开头容易被 exec 解析成 flag 而非命令
65
+ if (trimmed.startsWith('-')) {
66
+ return {
67
+ ok: false,
68
+ warnings,
69
+ rejectReason: '命令不能以 - 开头(避免被误解析为选项)'
70
+ }
71
+ }
72
+ // 危险模式检测 — 仅警告不阻止,因为这是本地 CLI,用户明确输入
73
+ if (/\brm\s+(-\w+\s+)*\/(?:\s|$|\*)/.test(trimmed)) {
74
+ warnings.push('检测到 `rm -<flag> /` 模式,确认这不是误输入?')
75
+ }
76
+ if (/:?\(?\)\s*\{.*:\|:.*\}/.test(trimmed)) {
77
+ warnings.push('检测到 fork bomb 模式(:(){ :|:& };:),确定要执行?')
78
+ }
79
+ if (/\bchmod\s+(-\w+\s+)*\s*777\s+\//.test(trimmed)) {
80
+ warnings.push('检测到 `chmod 777 /` 模式,会放开根目录权限')
81
+ }
82
+ return { ok: true, warnings }
83
+ }
84
+
85
+ /**
86
+ * 判断是否启用严格模式(--cmd-strict 表示禁用 shell 特性)
87
+ *
88
+ * @param {readonly string[]} argv
89
+ * @returns {boolean}
90
+ */
91
+ export function isCmdStrictMode(argv) {
92
+ if (!Array.isArray(argv)) return false
93
+ return argv.includes('--cmd-strict')
94
+ }