zen-gitsync 2.13.30 → 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/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-CFkZrkhd.js → AppVersionBadge-Ci_-H7TK.js} +2 -2
- 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-DKwcJRbO.js → CommandConsole-DUu75Lvw.js} +8 -8
- package/src/ui/public/assets/CommandConsole-Da3j09oc.css +1 -0
- package/src/ui/public/assets/{CommitForm-w_CCHowb.js → CommitForm-asrs-955.js} +2 -2
- package/src/ui/public/assets/{CustomCommandManager-C81Su49z.js → CustomCommandManager-BBnTU2pp.js} +1 -1
- 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-Bhcnqca8.js → FlowExecutionViewer-DgQs18Ym.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-Cb6C2RyD.js → FlowOrchestrationWorkspace-CEM_0XAJ.js} +2 -2
- package/src/ui/public/assets/{GitStatus-D90r1nV4.js → GitStatus-DedZ2a63.js} +2 -2
- package/src/ui/public/assets/{ImagePreview-kHYBPYW5.js → ImagePreview-XjBK0T26.js} +1 -1
- package/src/ui/public/assets/{LogList-D6nSWEwN.js → LogList-CUn9TvU1.js} +3 -3
- package/src/ui/public/assets/{ProjectStartupButton-BliaT11B.js → ProjectStartupButton-DWfKVcJo.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-CUs9RQZg.js → RemoteRepoCard-BVRJ8eAD.js} +1 -1
- package/src/ui/public/assets/{ResetToRemoteButton-BBsIG2FU.js → ResetToRemoteButton-CDhmy9Qj.js} +1 -1
- 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-Clov9VqY.js → TemplateManager-CogQCagT.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-yVqO69ll.js → UserInputNode-BcIaDC-e.js} +1 -1
- 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-CpTW8798.js → _plugin-vue_export-helper-BWJlNHzL.js} +3 -3
- package/src/ui/public/assets/index-BkpNzYyv.js +7 -0
- package/src/ui/public/assets/index-CNhCnXkD.css +1 -0
- package/src/ui/public/index.html +3 -3
- 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-KZ64lrZQ.css +0 -1
- package/src/ui/public/assets/BranchSelector-BzaBCj1X.js +0 -1
- package/src/ui/public/assets/CommandConsole-60KxfUE3.css +0 -1
- package/src/ui/public/assets/EditorView-Do6ivdLH.js +0 -0
- package/src/ui/public/assets/FileDiffViewer-CXMo-Rez.js +0 -13
- package/src/ui/public/assets/SourceMapView-Cvp-7LtP.js +0 -3
- package/src/ui/public/assets/SourceMapView-DTsSdcgG.css +0 -1
- package/src/ui/public/assets/WorkbenchView-BDYbbkM1.js +0 -12
- package/src/ui/public/assets/WorkbenchView-zUE7sv6z.css +0 -1
- package/src/ui/public/assets/index-B666KMk3.js +0 -7
- package/src/ui/public/assets/index-BYAxMGne.css +0 -1
|
@@ -0,0 +1,143 @@
|
|
|
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/customCommand.js 单元测试。
|
|
16
|
+
// 覆盖 validateCustomCommand(输入校验)与 isCmdStrictMode(--cmd-strict 检测)。
|
|
17
|
+
import { test } from 'node:test'
|
|
18
|
+
import assert from 'node:assert/strict'
|
|
19
|
+
import { validateCustomCommand, isCmdStrictMode, MAX_CUSTOM_CMD_LENGTH } from './customCommand.js'
|
|
20
|
+
|
|
21
|
+
// ========== validateCustomCommand: 入参类型 ==========
|
|
22
|
+
|
|
23
|
+
test('validateCustomCommand: 非字符串直接拒绝', () => {
|
|
24
|
+
for (const bad of [null, undefined, 123, true, [], {}, Symbol('x')]) {
|
|
25
|
+
const r = validateCustomCommand(bad)
|
|
26
|
+
assert.equal(r.ok, false, `validateCustomCommand(${String(bad)}) 应拒绝`)
|
|
27
|
+
assert.ok(r.rejectReason, '应给出拒绝原因')
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('validateCustomCommand: 空字符串 / 纯空白拒绝', () => {
|
|
32
|
+
assert.equal(validateCustomCommand('').ok, false)
|
|
33
|
+
assert.equal(validateCustomCommand(' ').ok, false)
|
|
34
|
+
assert.equal(validateCustomCommand('\n\t').ok, false)
|
|
35
|
+
assert.match(validateCustomCommand('').rejectReason, /不能为空/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// ========== validateCustomCommand: 长度限制 ==========
|
|
39
|
+
|
|
40
|
+
test('validateCustomCommand: 长度等于 MAX_CUSTOM_CMD_LENGTH 通过', () => {
|
|
41
|
+
const cmd = 'echo ' + 'a'.repeat(MAX_CUSTOM_CMD_LENGTH - 5)
|
|
42
|
+
assert.equal(cmd.length, MAX_CUSTOM_CMD_LENGTH)
|
|
43
|
+
const r = validateCustomCommand(cmd)
|
|
44
|
+
assert.equal(r.ok, true, `length=${cmd.length} 应通过`)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('validateCustomCommand: 长度超过上限拒绝', () => {
|
|
48
|
+
const cmd = 'echo ' + 'a'.repeat(MAX_CUSTOM_CMD_LENGTH) // +1 over
|
|
49
|
+
const r = validateCustomCommand(cmd)
|
|
50
|
+
assert.equal(r.ok, false)
|
|
51
|
+
assert.match(r.rejectReason, /过长/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// ========== validateCustomCommand: - 开头拒绝 ==========
|
|
55
|
+
|
|
56
|
+
test('validateCustomCommand: 以 - 开头的命令拒绝(避免 exec 误解析为 flag)', () => {
|
|
57
|
+
for (const cmd of ['-rf /', '--help', '-v', ' -something']) {
|
|
58
|
+
const r = validateCustomCommand(cmd)
|
|
59
|
+
assert.equal(r.ok, false, `"${cmd}" 应被拒绝`)
|
|
60
|
+
assert.match(r.rejectReason, /-/)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ========== validateCustomCommand: 正常命令通过 ==========
|
|
65
|
+
|
|
66
|
+
test('validateCustomCommand: 正常命令通过且无 warning', () => {
|
|
67
|
+
for (const cmd of [
|
|
68
|
+
'echo hello',
|
|
69
|
+
'npm run dev',
|
|
70
|
+
'git status',
|
|
71
|
+
'ls -la',
|
|
72
|
+
'echo "hello world"',
|
|
73
|
+
"echo 'with quotes'",
|
|
74
|
+
'cmd1 | cmd2', // shell 管道(本地 CLI 允许)
|
|
75
|
+
'cmd > out.txt', // shell 重定向
|
|
76
|
+
'echo $HOME', // shell 变量
|
|
77
|
+
'cmd && other', // shell 列表
|
|
78
|
+
]) {
|
|
79
|
+
const r = validateCustomCommand(cmd)
|
|
80
|
+
assert.equal(r.ok, true, `"${cmd}" 应通过`)
|
|
81
|
+
assert.equal(r.warnings.length, 0, `"${cmd}" 不应有 warning`)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ========== validateCustomCommand: 危险模式 warning ==========
|
|
86
|
+
|
|
87
|
+
test('validateCustomCommand: rm -<flag> / 模式 warning', () => {
|
|
88
|
+
const r1 = validateCustomCommand('rm -rf /')
|
|
89
|
+
assert.equal(r1.ok, true, '本地 CLI,警告而非阻止')
|
|
90
|
+
assert.ok(r1.warnings.some(w => /rm/.test(w)), '应有 rm 警告')
|
|
91
|
+
|
|
92
|
+
const r2 = validateCustomCommand('rm -fr /*')
|
|
93
|
+
assert.equal(r2.ok, true)
|
|
94
|
+
assert.ok(r2.warnings.some(w => /rm/.test(w)))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('validateCustomCommand: fork bomb 模式 warning', () => {
|
|
98
|
+
// 经典 fork bomb: :(){ :|:& };:
|
|
99
|
+
const r = validateCustomCommand(':(){ :|:& };:')
|
|
100
|
+
assert.equal(r.ok, true)
|
|
101
|
+
assert.ok(r.warnings.some(w => /fork bomb/i.test(w)))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('validateCustomCommand: chmod 777 / 模式 warning', () => {
|
|
105
|
+
const r = validateCustomCommand('chmod -R 777 /')
|
|
106
|
+
assert.equal(r.ok, true)
|
|
107
|
+
assert.ok(r.warnings.some(w => /chmod/.test(w)))
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('validateCustomCommand: 普通 chmod 不 warning', () => {
|
|
111
|
+
// chmod 644 a.txt 不应触发(危险模式只匹配根目录)
|
|
112
|
+
const r = validateCustomCommand('chmod 644 a.txt')
|
|
113
|
+
assert.equal(r.ok, true)
|
|
114
|
+
assert.equal(r.warnings.length, 0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('validateCustomCommand: rm 局部文件不 warning', () => {
|
|
118
|
+
// rm -rf build/ 不应触发(只匹配根目录形式)
|
|
119
|
+
const r = validateCustomCommand('rm -rf build/')
|
|
120
|
+
assert.equal(r.ok, true)
|
|
121
|
+
assert.equal(r.warnings.length, 0)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// ========== isCmdStrictMode ==========
|
|
125
|
+
|
|
126
|
+
test('isCmdStrictMode: argv 含 --cmd-strict 返回 true', () => {
|
|
127
|
+
assert.equal(isCmdStrictMode(['node', 'g.js', '--cmd-strict']), true)
|
|
128
|
+
assert.equal(isCmdStrictMode(['--cmd=echo hi', '--cmd-strict']), true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('isCmdStrictMode: argv 不含 --cmd-strict 返回 false', () => {
|
|
132
|
+
assert.equal(isCmdStrictMode(['node', 'g.js', '--cmd=echo hi']), false)
|
|
133
|
+
assert.equal(isCmdStrictMode([]), false)
|
|
134
|
+
// 相似名字不误匹配
|
|
135
|
+
assert.equal(isCmdStrictMode(['--cmd-strict-mode']), false)
|
|
136
|
+
assert.equal(isCmdStrictMode(['--strict-cmd']), false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('isCmdStrictMode: 非数组入参返回 false', () => {
|
|
140
|
+
assert.equal(isCmdStrictMode(null), false)
|
|
141
|
+
assert.equal(isCmdStrictMode(undefined), false)
|
|
142
|
+
assert.equal(isCmdStrictMode('--cmd-strict'), false) // 字符串而非数组
|
|
143
|
+
})
|
package/src/cli/ui.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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 共用 UI 工具:
|
|
16
|
+
// - boxenAdaptive(): boxen 包装,宽度自适应终端(避免 80 列硬编码在窄终端换行难看)
|
|
17
|
+
// - 预留 messages 表的导入入口(后续把硬编码中文迁移过来)
|
|
18
|
+
//
|
|
19
|
+
// 当前会话先落地 boxen 宽度自适应 — messages 表作为下轮 i18n 的过渡形态,
|
|
20
|
+
// 避免一次性大改风险。
|
|
21
|
+
|
|
22
|
+
import boxen from 'boxen'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 默认 boxen 宽度 — 当 process.stdout.columns 不可用(管道/CI)时使用
|
|
26
|
+
*/
|
|
27
|
+
const FALLBACK_TERMINAL_WIDTH = 100
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 计算 boxen 的 width 选项,基于终端列数 + 留出 padding/margin 空间
|
|
31
|
+
*
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {number} [opts.headroom=6] - 留给 padding(2) + margin(2) + 边框(2) 的总列数
|
|
34
|
+
* @returns {number} 用于 boxen options.width
|
|
35
|
+
*/
|
|
36
|
+
export function calcBoxenWidth({ headroom = 6 } = {}) {
|
|
37
|
+
const cols = process.stdout?.columns
|
|
38
|
+
if (typeof cols !== 'number' || cols <= 0 || !Number.isFinite(cols)) {
|
|
39
|
+
return FALLBACK_TERMINAL_WIDTH
|
|
40
|
+
}
|
|
41
|
+
// cols 已经包含 padding,boxen 的 width 选项是 "内部内容宽度" 还是 "总宽度" 与 version 有关
|
|
42
|
+
// boxen 8.x: width = 内框内容宽度(不含 padding/border)
|
|
43
|
+
// 我们设 width = cols - headroom,让 boxen 自己处理 padding
|
|
44
|
+
const w = cols - headroom
|
|
45
|
+
return w > 0 ? w : FALLBACK_TERMINAL_WIDTH
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* boxen 包装,宽度自适应终端
|
|
50
|
+
*
|
|
51
|
+
* @param {string} message - 内容
|
|
52
|
+
* @param {object} [options] - 透传给 boxen 的选项(width 会覆盖)
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function boxenAdaptive(message, options = {}) {
|
|
56
|
+
// 用户显式传 width 时尊重用户;否则按终端自适应
|
|
57
|
+
const width = options.width ?? calcBoxenWidth()
|
|
58
|
+
return boxen(message, { ...options, width })
|
|
59
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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/ui.js 单元测试 — boxen 宽度自适应。
|
|
16
|
+
import { test, afterEach } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { calcBoxenWidth, boxenAdaptive } from './ui.js'
|
|
19
|
+
|
|
20
|
+
// 保留原始 stdout.columns 用于恢复
|
|
21
|
+
const originalColumns = process.stdout.columns
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// 恢复 columns(有些 Node 版本 columns 是 getter,需要用 defineProperty)
|
|
24
|
+
try {
|
|
25
|
+
Object.defineProperty(process.stdout, 'columns', {
|
|
26
|
+
value: originalColumns,
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true,
|
|
29
|
+
})
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function setColumns(value) {
|
|
34
|
+
Object.defineProperty(process.stdout, 'columns', {
|
|
35
|
+
value,
|
|
36
|
+
configurable: true,
|
|
37
|
+
writable: true,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ========== calcBoxenWidth ==========
|
|
42
|
+
|
|
43
|
+
test('calcBoxenWidth: 正常 columns 返回 cols - headroom', () => {
|
|
44
|
+
setColumns(120)
|
|
45
|
+
assert.equal(calcBoxenWidth(), 120 - 6)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('calcBoxenWidth: 自定义 headroom 生效', () => {
|
|
49
|
+
setColumns(120)
|
|
50
|
+
assert.equal(calcBoxenWidth({ headroom: 10 }), 110)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('calcBoxenWidth: 窄终端仍返回正数', () => {
|
|
54
|
+
setColumns(10)
|
|
55
|
+
const w = calcBoxenWidth()
|
|
56
|
+
// 10 - 6 = 4,正数
|
|
57
|
+
assert.ok(w > 0, `窄终端 width 应为正,实际 ${w}`)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('calcBoxenWidth: 极窄终端回退到 FALLBACK', () => {
|
|
61
|
+
setColumns(2)
|
|
62
|
+
const w = calcBoxenWidth()
|
|
63
|
+
// 2 - 6 < 0,回退 100
|
|
64
|
+
assert.equal(w, 100)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('calcBoxenWidth: columns 为 0 / undefined / 非数字时回退', () => {
|
|
68
|
+
setColumns(undefined)
|
|
69
|
+
assert.equal(calcBoxenWidth(), 100)
|
|
70
|
+
setColumns(0)
|
|
71
|
+
assert.equal(calcBoxenWidth(), 100)
|
|
72
|
+
setColumns(NaN)
|
|
73
|
+
assert.equal(calcBoxenWidth(), 100)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ========== boxenAdaptive ==========
|
|
77
|
+
|
|
78
|
+
test('boxenAdaptive: 返回字符串且包含 message 内容', () => {
|
|
79
|
+
setColumns(120)
|
|
80
|
+
const out = boxenAdaptive('hello world', { padding: 1 })
|
|
81
|
+
assert.equal(typeof out, 'string')
|
|
82
|
+
assert.ok(out.includes('hello world'), '输出应包含原始 message')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('boxenAdaptive: 默认 width 自适应终端(可通过 boxen 8.x 截断观察)', () => {
|
|
86
|
+
// 验证 boxen 调用确实传入了 width(而非 options 被忽略)
|
|
87
|
+
// boxen 8.x 在 width 不足时会换行,我们只验证函数能跑通 + 返回 string
|
|
88
|
+
setColumns(40)
|
|
89
|
+
const out = boxenAdaptive('a'.repeat(200), { padding: 0 })
|
|
90
|
+
assert.ok(out.length > 0)
|
|
91
|
+
// boxen 8.x 默认会保留 message 内容(可能换行),不应被截掉
|
|
92
|
+
// 不严格断言保留多少字符 — boxen 内部 wordWrap 实现可能切到任意位置
|
|
93
|
+
assert.ok(out.includes('aaa'), '应至少保留 message 的开头')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('boxenAdaptive: 用户显式传 width 时尊重用户(不被自适应覆盖)', () => {
|
|
97
|
+
setColumns(120)
|
|
98
|
+
// 用户传 width: 60,即使终端 120 也按 60 处理
|
|
99
|
+
// 我们用 boxen 8 的渲染:width=60 时单行内容应 < 60
|
|
100
|
+
const out = boxenAdaptive('short', { width: 20 })
|
|
101
|
+
// boxen 8.x 加 padding/border 后总宽度 > 20,但单行 message "short" 仍存在
|
|
102
|
+
assert.ok(out.includes('short'))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('boxenAdaptive: 自适应下窄终端的 box 不会超宽', () => {
|
|
106
|
+
setColumns(30)
|
|
107
|
+
// boxen 8 默认行为:width 是内框宽度,总宽度 = width + padding*2 + border*2
|
|
108
|
+
// 30 - 6 (headroom) = 24 width
|
|
109
|
+
// 总宽度 = 24 + 2*1 + 2 = 30,与 cols 对齐
|
|
110
|
+
const out = boxenAdaptive('x', { padding: 1 })
|
|
111
|
+
// 检查每行长度都不超过 cols + 少量余量(ANSI 转义不影响字符数,但我们按 \n split 数 line length)
|
|
112
|
+
// 简单起见:确保渲染成功
|
|
113
|
+
assert.ok(out.length > 0)
|
|
114
|
+
assert.ok(out.includes('x'))
|
|
115
|
+
})
|
package/src/config.js
CHANGED
|
@@ -176,24 +176,36 @@ async function loadConfig() {
|
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// 配置写入失败的错误类型,让上层 catch 后能区分是参数错误还是 IO 错误
|
|
180
|
+
class ConfigWriteError extends Error {
|
|
181
|
+
constructor(message, cause) {
|
|
182
|
+
super(message);
|
|
183
|
+
this.name = 'ConfigWriteError';
|
|
184
|
+
if (cause) this.cause = cause;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
179
188
|
// 异步保存配置
|
|
189
|
+
// 契约变更(2026-06-26):之前在错误分支仅 console.warn + return undefined,
|
|
190
|
+
// 调用方按成功处理 → 用户看到 ✓ / 200 OK 但其实没写盘(MAINT-4)。
|
|
191
|
+
// 现在改为:参数非法 → 抛 ConfigWriteError;IO/解析失败 → 抛 ConfigWriteError(wrapped)。
|
|
192
|
+
// 成功路径仍然返回 undefined,保持与大多数 fs.writeFile 风格一致。
|
|
180
193
|
async function saveConfig(config) {
|
|
181
194
|
if(!config || typeof config !== 'object' || Array.isArray(config)){
|
|
182
|
-
|
|
183
|
-
console.warn(chalk.gray(`文件: ${configPath}`));
|
|
184
|
-
return;
|
|
195
|
+
throw new ConfigWriteError(`saveConfig: 入参必须是普通对象,实际收到 ${Array.isArray(config) ? 'array' : typeof config}`);
|
|
185
196
|
}
|
|
186
197
|
if (Object.keys(config).length === 0) {
|
|
187
|
-
|
|
188
|
-
console.warn(chalk.gray(`文件: ${configPath}`));
|
|
189
|
-
return;
|
|
198
|
+
throw new ConfigWriteError('saveConfig: 配置对象为空,已取消写入以避免覆盖');
|
|
190
199
|
}
|
|
191
200
|
const key = getCurrentProjectKey();
|
|
192
201
|
const state = await safeLoadRaw();
|
|
193
202
|
if (!state.ok) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
const cause = state.error;
|
|
204
|
+
const detail = cause?.message ? String(cause.message) : String(cause);
|
|
205
|
+
throw new ConfigWriteError(
|
|
206
|
+
`解析配置文件失败,已取消写入以避免覆盖。\n文件: ${configPath}\n原因: ${detail}`,
|
|
207
|
+
cause
|
|
208
|
+
);
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
const raw = state.obj; // 保留现有顶层键
|
|
@@ -234,7 +246,16 @@ async function lockFile(filePath) {
|
|
|
234
246
|
|
|
235
247
|
if (!config.lockedFiles.includes(normalizedPath)) {
|
|
236
248
|
config.lockedFiles.push(normalizedPath);
|
|
237
|
-
|
|
249
|
+
try {
|
|
250
|
+
await saveConfig(config);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
// 写入失败时回滚内存变更,避免"以为锁了实际没锁"
|
|
253
|
+
const idx = config.lockedFiles.lastIndexOf(normalizedPath);
|
|
254
|
+
if (idx > -1) config.lockedFiles.splice(idx, 1);
|
|
255
|
+
console.error(chalk.red(`❌ 锁定失败: "${normalizedPath}"`));
|
|
256
|
+
console.error(chalk.gray(err.message));
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
238
259
|
console.log(chalk.green(`✓ 文件已锁定: "${normalizedPath}"`));
|
|
239
260
|
return true;
|
|
240
261
|
} else {
|
|
@@ -250,7 +271,15 @@ async function unlockFile(filePath) {
|
|
|
250
271
|
|
|
251
272
|
if (index > -1) {
|
|
252
273
|
config.lockedFiles.splice(index, 1);
|
|
253
|
-
|
|
274
|
+
try {
|
|
275
|
+
await saveConfig(config);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
// 写入失败时回滚(把刚 splice 掉的项加回去)
|
|
278
|
+
config.lockedFiles.splice(index, 0, normalizedPath);
|
|
279
|
+
console.error(chalk.red(`❌ 解锁失败: "${normalizedPath}"`));
|
|
280
|
+
console.error(chalk.gray(err.message));
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
254
283
|
console.log(chalk.green(`✓ 文件已解锁: "${normalizedPath}"`));
|
|
255
284
|
return true;
|
|
256
285
|
} else {
|
|
@@ -296,17 +325,24 @@ async function saveRecentDirectory(dirPath) {
|
|
|
296
325
|
if (!dirPath || typeof dirPath !== 'string') return;
|
|
297
326
|
const state = await safeLoadRaw();
|
|
298
327
|
if (!state.ok) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
328
|
+
const cause = state.error;
|
|
329
|
+
const detail = cause?.message ? String(cause.message) : String(cause);
|
|
330
|
+
throw new ConfigWriteError(
|
|
331
|
+
`解析配置文件失败,已取消写入最近目录以避免覆盖。\n文件: ${configPath}\n原因: ${detail}`,
|
|
332
|
+
cause
|
|
333
|
+
);
|
|
302
334
|
}
|
|
303
335
|
const raw = state.obj; // 保留现有顶层键
|
|
304
336
|
let list = Array.isArray(raw.recentDirectories) ? raw.recentDirectories.slice() : [];
|
|
305
337
|
|
|
306
|
-
//
|
|
338
|
+
// 规范化:Windows 下统一为小写,去掉重复,保持最新在前
|
|
339
|
+
// 修复(MAINT-5):unshift 用原始大小写,Windows 下 C:\Project 与 C:\PROJECT
|
|
340
|
+
// 会产生两条"看起来一样"的项;现在 unshift 前先 normalize,dup 检测自然合并。
|
|
341
|
+
// 注意:存的字符串是 normalized 形式,在 Windows 下显示为小写路径,
|
|
342
|
+
// 这是有意为之 — 用 OS 标准大小写形式消除歧义,GUI 层可选择单独保留原始形式。
|
|
307
343
|
const normalized = normalizeProjectPath(dirPath);
|
|
308
344
|
list = list.filter(p => normalizeProjectPath(p) !== normalized);
|
|
309
|
-
list.unshift(
|
|
345
|
+
list.unshift(normalized);
|
|
310
346
|
if (list.length > MAX_RECENT_DIRS) list = list.slice(0, MAX_RECENT_DIRS);
|
|
311
347
|
|
|
312
348
|
raw.recentDirectories = list;
|
|
@@ -341,7 +377,13 @@ async function handleConfigCommands() {
|
|
|
341
377
|
const newMessage = setMsgArg.split('=')[1];
|
|
342
378
|
const currentConfig = await loadConfig();
|
|
343
379
|
currentConfig.defaultCommitMessage = newMessage;
|
|
344
|
-
|
|
380
|
+
try {
|
|
381
|
+
await saveConfig(currentConfig);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.error(chalk.red('❌ 默认提交信息写入失败'));
|
|
384
|
+
console.error(chalk.gray(err.message));
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
345
387
|
console.log(chalk.green(`✓ 默认提交信息已设置为: "${newMessage}"`));
|
|
346
388
|
process.exit();
|
|
347
389
|
}
|
|
@@ -361,3 +403,6 @@ export default {
|
|
|
361
403
|
readRawConfigFile,
|
|
362
404
|
writeRawConfigFile
|
|
363
405
|
};
|
|
406
|
+
|
|
407
|
+
// 命名导出 — 用于测试与外部复用
|
|
408
|
+
export { ConfigWriteError, normalizeProjectPath };
|
package/src/gitCommit.js
CHANGED
|
@@ -28,15 +28,21 @@ import { format as dateFormat } from 'date-fns/format';
|
|
|
28
28
|
import logUpdate from 'log-update';
|
|
29
29
|
import startUIServer from './ui/server/index.js';
|
|
30
30
|
import { exec } from 'child_process';
|
|
31
|
+
import {
|
|
32
|
+
registerCleanup, trackChild, setupSigintHandler,
|
|
33
|
+
} from './cli/cleanup.js';
|
|
34
|
+
import { validateCustomCommand, isCmdStrictMode } from './cli/customCommand.js';
|
|
35
|
+
import { boxenAdaptive } from './cli/ui.js';
|
|
31
36
|
|
|
32
37
|
let countdownInterval = null;
|
|
33
38
|
|
|
34
39
|
function startCountdown(interval) {
|
|
35
40
|
let remaining = interval;
|
|
36
41
|
|
|
37
|
-
// 清除旧的倒计时
|
|
42
|
+
// 清除旧的倒计时(同一时刻只允许一个倒计时可见)
|
|
38
43
|
if (countdownInterval) {
|
|
39
44
|
clearInterval(countdownInterval);
|
|
45
|
+
countdownInterval = null;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
const render = () => {
|
|
@@ -52,7 +58,7 @@ function startCountdown(interval) {
|
|
|
52
58
|
chalk.dim('按 Ctrl+C 终止进程')
|
|
53
59
|
].join('\n');
|
|
54
60
|
|
|
55
|
-
const box =
|
|
61
|
+
const box = boxenAdaptive(message, {
|
|
56
62
|
padding: 1,
|
|
57
63
|
margin: 1,
|
|
58
64
|
borderColor: 'cyan',
|
|
@@ -74,12 +80,21 @@ function startCountdown(interval) {
|
|
|
74
80
|
|
|
75
81
|
if (remaining <= 0) {
|
|
76
82
|
clearInterval(countdownInterval);
|
|
83
|
+
countdownInterval = null;
|
|
77
84
|
logUpdate.clear();
|
|
78
85
|
return;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
render();
|
|
82
89
|
}, 1000);
|
|
90
|
+
// 注册到 SIGINT cleanup 表,Ctrl+C 时一并清掉,避免倒计时残影
|
|
91
|
+
registerCleanup('countdown', () => {
|
|
92
|
+
if (countdownInterval) {
|
|
93
|
+
clearInterval(countdownInterval);
|
|
94
|
+
countdownInterval = null;
|
|
95
|
+
}
|
|
96
|
+
try { logUpdate.clear(); } catch (_) {}
|
|
97
|
+
});
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
const {loadConfig, saveConfig, handleConfigCommands} = config;
|
|
@@ -182,6 +197,18 @@ async function handleFileLockCommands() {
|
|
|
182
197
|
async function main() {
|
|
183
198
|
judgePlatform()
|
|
184
199
|
|
|
200
|
+
// 一次性注册 SIGINT 处理器 — 所有长跑流程(定时 commit / 自定义命令循环 /
|
|
201
|
+
// 倒计时)都通过 registerCleanup 上报,SIGINT 时由统一处理器 drain。
|
|
202
|
+
setupSigintHandler({
|
|
203
|
+
onBeforeCleanup: () => {
|
|
204
|
+
try { logUpdate.clear(); } catch (_) {}
|
|
205
|
+
console.log(chalk.yellow('\n🛑 收到终止信号,清理中...'))
|
|
206
|
+
},
|
|
207
|
+
onAfterCleanup: () => {
|
|
208
|
+
console.log(chalk.yellow('🛑 已清理,退出'))
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
185
212
|
// 检查是否是UI命令
|
|
186
213
|
if (process.argv.includes('ui')) {
|
|
187
214
|
await startUIServer(false, false); // 传入noOpen=false, savePort=false
|
|
@@ -218,7 +245,22 @@ async function main() {
|
|
|
218
245
|
const cmdMatch = cmdArg.match(/^--cmd=(['"]?)(.*)\1$/);
|
|
219
246
|
let cmd = cmdMatch ? cmdMatch[2] : '';
|
|
220
247
|
cmd = cmd.replace(/%%/g, '%'); // 关键修复
|
|
221
|
-
|
|
248
|
+
|
|
249
|
+
// 输入校验(SEC-CLI-1) — 本地 CLI,用户明确输入,所以检测而非阻止:
|
|
250
|
+
// 拒绝对抗性输入(空 / - 开头 / 超长);危险模式打 warning。
|
|
251
|
+
const cmdValidation = validateCustomCommand(cmd);
|
|
252
|
+
if (!cmdValidation.ok) {
|
|
253
|
+
console.error(chalk.red(`❌ --cmd 校验失败: ${cmdValidation.rejectReason}`));
|
|
254
|
+
process.exit(2); // 2 = 命令行参数错误(Bash 惯例)
|
|
255
|
+
}
|
|
256
|
+
for (const w of cmdValidation.warnings) {
|
|
257
|
+
console.warn(chalk.yellow(`⚠️ ${w}`));
|
|
258
|
+
}
|
|
259
|
+
// 显式 --cmd-strict 时打一条提示,让用户知道 shell 特性被禁用
|
|
260
|
+
if (isCmdStrictMode(process.argv)) {
|
|
261
|
+
console.log(chalk.dim('ℹ️ --cmd-strict 模式:将通过 execFile 拆 argv 执行,管道/重定向/通配符将不可用'));
|
|
262
|
+
}
|
|
263
|
+
|
|
222
264
|
const atArg = process.argv.find(arg => arg.startsWith('--at='));
|
|
223
265
|
const intervalArg = process.argv.find(arg => arg.startsWith('--cmd-interval='));
|
|
224
266
|
|
|
@@ -229,13 +271,13 @@ async function main() {
|
|
|
229
271
|
|
|
230
272
|
const runOnce = () => {
|
|
231
273
|
console.log(`\n[自定义命令执行] ${new Date().toLocaleString()}\n> ${cmd}`);
|
|
232
|
-
exec(cmd, (err, stdout, stderr) => {
|
|
274
|
+
trackChild(exec(cmd, (err, stdout, stderr) => {
|
|
233
275
|
if (err) {
|
|
234
276
|
console.error(`[自定义命令错误]`, err.message);
|
|
235
277
|
}
|
|
236
278
|
if (stdout) console.log(`[自定义命令输出]\n${stdout}`);
|
|
237
279
|
if (stderr) console.error(`[自定义命令错误输出]\n${stderr}`);
|
|
238
|
-
});
|
|
280
|
+
}));
|
|
239
281
|
};
|
|
240
282
|
|
|
241
283
|
const getNextTarget = (now) => {
|
|
@@ -283,43 +325,48 @@ async function main() {
|
|
|
283
325
|
runOnce();
|
|
284
326
|
if (repeatDaily) scheduleNext();
|
|
285
327
|
}, delay);
|
|
328
|
+
// 注册到 SIGINT cleanup 表 — Ctrl+C 时由统一处理器清理
|
|
329
|
+
registerCleanup('atTimer', () => {
|
|
330
|
+
if (atTimer) {
|
|
331
|
+
clearTimeout(atTimer);
|
|
332
|
+
atTimer = null;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
286
335
|
};
|
|
287
336
|
|
|
288
337
|
scheduleNext();
|
|
289
|
-
|
|
290
|
-
process.on('SIGINT', () => {
|
|
291
|
-
if (atTimer) clearTimeout(atTimer);
|
|
292
|
-
process.exit();
|
|
293
|
-
});
|
|
338
|
+
// 旧的内联 SIGINT handler 已被 setupSigintHandler 替代,这里不再重复注册
|
|
294
339
|
} else if (intervalArg) {
|
|
295
340
|
// 定时循环执行
|
|
296
341
|
const intervalMatch = intervalArg.match(/^--cmd-interval=(['"]?)(.*)\1$/);
|
|
297
342
|
const interval = intervalMatch ? parseInt(intervalMatch[2], 10) * 1000 : 0;
|
|
298
343
|
if (interval > 0) {
|
|
299
344
|
console.log(`每隔 ${interval/1000} 秒执行: ${cmd}`);
|
|
300
|
-
setInterval(() => {
|
|
345
|
+
const cmdIntervalId = setInterval(() => {
|
|
301
346
|
console.log(`\n[自定义命令执行] ${new Date().toLocaleString()}\n> ${cmd}`);
|
|
302
|
-
exec(cmd, (err, stdout, stderr) => {
|
|
347
|
+
trackChild(exec(cmd, (err, stdout, stderr) => {
|
|
303
348
|
if (err) {
|
|
304
349
|
console.error(`[自定义命令错误]`, err.message);
|
|
305
350
|
}
|
|
306
351
|
if (stdout) console.log(`[自定义命令输出]\n${stdout}`);
|
|
307
352
|
if (stderr) console.error(`[自定义命令错误输出]\n${stderr}`);
|
|
308
|
-
});
|
|
353
|
+
}));
|
|
309
354
|
}, interval);
|
|
355
|
+
// 注册 cleanup,Ctrl+C 时清掉这个 interval(否则进程不退)
|
|
356
|
+
registerCleanup('cmdInterval', () => clearInterval(cmdIntervalId));
|
|
310
357
|
} else {
|
|
311
358
|
console.error('无效的时间间隔参数');
|
|
312
359
|
}
|
|
313
360
|
} else {
|
|
314
361
|
// 立即执行一次
|
|
315
362
|
console.log(`[自定义命令立即执行] > ${cmd}`);
|
|
316
|
-
exec(cmd, (err, stdout, stderr) => {
|
|
363
|
+
trackChild(exec(cmd, (err, stdout, stderr) => {
|
|
317
364
|
if (err) {
|
|
318
365
|
console.error(`[自定义命令错误]`, err.message);
|
|
319
366
|
}
|
|
320
367
|
if (stdout) console.log(`[自定义命令输出]\n${stdout}`);
|
|
321
368
|
if (stderr) console.error(`[自定义命令错误输出]\n${stderr}`);
|
|
322
|
-
});
|
|
369
|
+
}));
|
|
323
370
|
}
|
|
324
371
|
}
|
|
325
372
|
// ========== 新增功能结束 ==========
|
|
@@ -355,12 +402,21 @@ const commitAndSchedule = async (interval) => {
|
|
|
355
402
|
try {
|
|
356
403
|
await createGitCommit({exit: false});
|
|
357
404
|
// await delay(2000)
|
|
358
|
-
startCountdown(interval); // 启动倒计时
|
|
405
|
+
startCountdown(interval); // 启动倒计时(内部已注册 cleanup)
|
|
359
406
|
|
|
360
407
|
// 设置定时提交
|
|
361
408
|
timer = setTimeout(async () => {
|
|
362
409
|
await commitAndSchedule(interval);
|
|
363
410
|
}, interval + 100);
|
|
411
|
+
// 注册到 SIGINT cleanup 表 — Ctrl+C 时统一清掉
|
|
412
|
+
// 注意:cleanup 命名 'commitTimer',每次递归都覆盖上一次的 handler
|
|
413
|
+
// (cleanupTasks.set 同名覆盖语义),保证只清当前 pending 那个 setTimeout
|
|
414
|
+
registerCleanup('commitTimer', () => {
|
|
415
|
+
if (timer) {
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
timer = null;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
364
420
|
} catch (error) {
|
|
365
421
|
console.error('提交出错:', error.message);
|
|
366
422
|
clearTimeout(timer);
|
|
@@ -381,15 +437,7 @@ const judgeInterval = async () => {
|
|
|
381
437
|
console.error(chalk.red.bold('定时提交致命错误:'), error.message);
|
|
382
438
|
process.exit(1);
|
|
383
439
|
}
|
|
384
|
-
|
|
385
|
-
// 处理退出清理
|
|
386
|
-
process.on('SIGINT', () => {
|
|
387
|
-
logUpdate.clear();
|
|
388
|
-
clearTimeout(timer);
|
|
389
|
-
clearInterval(countdownInterval);
|
|
390
|
-
console.log(chalk.yellow('\n🛑 定时任务已终止'));
|
|
391
|
-
process.exit();
|
|
392
|
-
});
|
|
440
|
+
// 旧的 inline SIGINT handler 已移除,改由 setupSigintHandler 统一处理
|
|
393
441
|
} else {
|
|
394
442
|
createGitCommit({exit: false});
|
|
395
443
|
}
|