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.
- package/README.md +2 -2
- package/package.json +3 -1
- package/src/cli/cleanup.js +179 -0
- package/src/cli/cleanup.test.js +269 -0
- package/src/cli/customCommand.js +94 -0
- package/src/cli/customCommand.test.js +143 -0
- package/src/cli/ui.js +59 -0
- package/src/cli/ui.test.js +115 -0
- package/src/config.js +62 -17
- package/src/gitCommit.js +73 -25
- package/src/ui/public/assets/AppVersionBadge-Ci_-H7TK.js +2 -0
- package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +1 -0
- package/src/ui/public/assets/BranchSelector-BIGlKQme.js +1 -0
- package/src/ui/public/assets/CommandConsole-DUu75Lvw.js +17 -0
- package/src/ui/public/assets/CommandConsole-Da3j09oc.css +1 -0
- package/src/ui/public/assets/CommitForm-asrs-955.js +6 -0
- package/src/ui/public/assets/CustomCommandManager-BBnTU2pp.js +1 -0
- package/src/ui/public/assets/EditorView-BYuHlMbn.js +0 -0
- package/src/ui/public/assets/{EditorView-Do_aRkqm.css → EditorView-BcZ8ToAk.css} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-ASPmnTdG.css → FileDiffViewer-CLvy7mIG.css} +1 -1
- package/src/ui/public/assets/FileDiffViewer-vOvrYF89.js +13 -0
- package/src/ui/public/assets/{FlowExecutionViewer-ADwYz1XL.js → FlowExecutionViewer-DgQs18Ym.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CEM_0XAJ.js +21 -0
- package/src/ui/public/assets/GitStatus-DedZ2a63.js +6 -0
- package/src/ui/public/assets/ImagePreview-XjBK0T26.js +1 -0
- package/src/ui/public/assets/LogList-CUn9TvU1.js +7 -0
- package/src/ui/public/assets/ProjectStartupButton-DWfKVcJo.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-BVRJ8eAD.js +1 -0
- package/src/ui/public/assets/ResetToRemoteButton-CDhmy9Qj.js +1 -0
- package/src/ui/public/assets/SourceMapView-CUq_XXYS.js +3 -0
- package/src/ui/public/assets/SourceMapView-DjYKJZLj.css +1 -0
- package/src/ui/public/assets/TemplateManager-CogQCagT.js +1 -0
- package/src/ui/public/assets/UserInputNode-BcIaDC-e.js +1 -0
- package/src/ui/public/assets/WorkbenchView-CGclOlIQ.js +12 -0
- package/src/ui/public/assets/WorkbenchView-ir769eTr.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-DKIb-p1R.js → _plugin-vue_export-helper-BWJlNHzL.js} +3 -3
- package/src/ui/public/assets/element-plus-CRTdimvR.js +24 -0
- package/src/ui/public/assets/{flow-mindmap-mZisamFd.js → flow-mindmap-oBAz3_QK.js} +4 -4
- package/src/ui/public/assets/index-BkpNzYyv.js +7 -0
- package/src/ui/public/assets/index-CNhCnXkD.css +1 -0
- package/src/ui/public/assets/vendor-BxotT5qN.js +93 -0
- package/src/ui/public/assets/vue-flow-Bx03URoq.js +39 -0
- package/src/ui/public/index.html +6 -6
- package/src/ui/server/index.js +1 -0
- package/src/ui/server/routes/npm.js +161 -8
- package/src/ui/server/utils/instanceRegistry.js +11 -0
- package/src/ui/server/utils/startServerOnAvailablePort.js +13 -0
- package/src/utils/index.js +42 -10
- package/src/utils/parseCwdArg.test.js +89 -0
- package/src/utils/utils.test.js +200 -0
- package/src/ui/public/assets/AppVersionBadge-CTUXSSX1.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-Dnl4p9oF.js +0 -2
- package/src/ui/public/assets/BranchSelector-C64gm1XB.js +0 -1
- package/src/ui/public/assets/CommandConsole-60KxfUE3.css +0 -1
- package/src/ui/public/assets/CommandConsole-CbufZ-aa.js +0 -17
- package/src/ui/public/assets/CommitForm-CVPUXS7N.js +0 -6
- package/src/ui/public/assets/CustomCommandManager-DYXfu8Gr.js +0 -1
- package/src/ui/public/assets/EditorView-DaLTdDZK.js +0 -0
- package/src/ui/public/assets/FileDiffViewer-BvL-9S74.js +0 -13
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DQhf9Uyr.js +0 -21
- package/src/ui/public/assets/GitStatus-CjNzYRV2.js +0 -6
- package/src/ui/public/assets/ImagePreview-Bo8Y4HdH.js +0 -1
- package/src/ui/public/assets/LogList-vdQwp7e5.js +0 -7
- package/src/ui/public/assets/ProjectStartupButton-CIkYLyt7.js +0 -1
- package/src/ui/public/assets/RemoteRepoCard-CpyLlD_n.js +0 -1
- package/src/ui/public/assets/ResetToRemoteButton-BElORAbP.js +0 -1
- package/src/ui/public/assets/SourceMapView-BS35GVi7.js +0 -3
- package/src/ui/public/assets/SourceMapView-DTsSdcgG.css +0 -1
- package/src/ui/public/assets/TemplateManager-B5s2lBKa.js +0 -1
- package/src/ui/public/assets/UserInputNode-CKoZYrQX.js +0 -1
- package/src/ui/public/assets/WorkbenchView-DKDEzEIV.js +0 -12
- package/src/ui/public/assets/WorkbenchView-MepwSID3.css +0 -1
- package/src/ui/public/assets/__vite-optional-peer-dep_katex_markstream-vue-Bg9-FXxr.js +0 -1
- package/src/ui/public/assets/__vite-optional-peer-dep_mermaid_markstream-vue-cPZl55sA.js +0 -1
- package/src/ui/public/assets/__vite-optional-peer-dep_stream-markdown_markstream-vue-Dhgp-lRL.js +0 -1
- package/src/ui/public/assets/__vite-optional-peer-dep_stream-monaco_markstream-vue-Cr1d6De7.js +0 -1
- package/src/ui/public/assets/d2_markstream-vue-xMoF3M5G.js +0 -1
- package/src/ui/public/assets/element-plus-rcpA4EGF.js +0 -26
- package/src/ui/public/assets/index-BYAxMGne.css +0 -1
- package/src/ui/public/assets/index-DJIlYzzK.js +0 -7
- package/src/ui/public/assets/mhchem_markstream-vue-tp86PqxG.js +0 -1
- package/src/ui/public/assets/vendor-DoD2dmIW.js +0 -226
- 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
|
|
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
|
|
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.
|
|
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
|
+
}
|