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.
Files changed (52) hide show
  1. package/package.json +3 -1
  2. package/src/cli/cleanup.js +179 -0
  3. package/src/cli/cleanup.test.js +269 -0
  4. package/src/cli/customCommand.js +94 -0
  5. package/src/cli/customCommand.test.js +143 -0
  6. package/src/cli/ui.js +59 -0
  7. package/src/cli/ui.test.js +115 -0
  8. package/src/config.js +62 -17
  9. package/src/gitCommit.js +73 -25
  10. package/src/ui/public/assets/{AppVersionBadge-CFkZrkhd.js → AppVersionBadge-Ci_-H7TK.js} +2 -2
  11. package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +1 -0
  12. package/src/ui/public/assets/BranchSelector-BIGlKQme.js +1 -0
  13. package/src/ui/public/assets/{CommandConsole-DKwcJRbO.js → CommandConsole-DUu75Lvw.js} +8 -8
  14. package/src/ui/public/assets/CommandConsole-Da3j09oc.css +1 -0
  15. package/src/ui/public/assets/{CommitForm-w_CCHowb.js → CommitForm-asrs-955.js} +2 -2
  16. package/src/ui/public/assets/{CustomCommandManager-C81Su49z.js → CustomCommandManager-BBnTU2pp.js} +1 -1
  17. package/src/ui/public/assets/EditorView-BYuHlMbn.js +0 -0
  18. package/src/ui/public/assets/{EditorView-Do_aRkqm.css → EditorView-BcZ8ToAk.css} +1 -1
  19. package/src/ui/public/assets/{FileDiffViewer-ASPmnTdG.css → FileDiffViewer-CLvy7mIG.css} +1 -1
  20. package/src/ui/public/assets/FileDiffViewer-vOvrYF89.js +13 -0
  21. package/src/ui/public/assets/{FlowExecutionViewer-Bhcnqca8.js → FlowExecutionViewer-DgQs18Ym.js} +1 -1
  22. package/src/ui/public/assets/{FlowOrchestrationWorkspace-Cb6C2RyD.js → FlowOrchestrationWorkspace-CEM_0XAJ.js} +2 -2
  23. package/src/ui/public/assets/{GitStatus-D90r1nV4.js → GitStatus-DedZ2a63.js} +2 -2
  24. package/src/ui/public/assets/{ImagePreview-kHYBPYW5.js → ImagePreview-XjBK0T26.js} +1 -1
  25. package/src/ui/public/assets/{LogList-D6nSWEwN.js → LogList-CUn9TvU1.js} +3 -3
  26. package/src/ui/public/assets/{ProjectStartupButton-BliaT11B.js → ProjectStartupButton-DWfKVcJo.js} +1 -1
  27. package/src/ui/public/assets/{RemoteRepoCard-CUs9RQZg.js → RemoteRepoCard-BVRJ8eAD.js} +1 -1
  28. package/src/ui/public/assets/{ResetToRemoteButton-BBsIG2FU.js → ResetToRemoteButton-CDhmy9Qj.js} +1 -1
  29. package/src/ui/public/assets/SourceMapView-CUq_XXYS.js +3 -0
  30. package/src/ui/public/assets/SourceMapView-DjYKJZLj.css +1 -0
  31. package/src/ui/public/assets/{TemplateManager-Clov9VqY.js → TemplateManager-CogQCagT.js} +1 -1
  32. package/src/ui/public/assets/{UserInputNode-yVqO69ll.js → UserInputNode-BcIaDC-e.js} +1 -1
  33. package/src/ui/public/assets/WorkbenchView-CGclOlIQ.js +12 -0
  34. package/src/ui/public/assets/WorkbenchView-ir769eTr.css +1 -0
  35. package/src/ui/public/assets/{_plugin-vue_export-helper-CpTW8798.js → _plugin-vue_export-helper-BWJlNHzL.js} +3 -3
  36. package/src/ui/public/assets/index-BkpNzYyv.js +7 -0
  37. package/src/ui/public/assets/index-CNhCnXkD.css +1 -0
  38. package/src/ui/public/index.html +3 -3
  39. package/src/utils/index.js +42 -10
  40. package/src/utils/parseCwdArg.test.js +89 -0
  41. package/src/utils/utils.test.js +200 -0
  42. package/src/ui/public/assets/AppVersionBadge-KZ64lrZQ.css +0 -1
  43. package/src/ui/public/assets/BranchSelector-BzaBCj1X.js +0 -1
  44. package/src/ui/public/assets/CommandConsole-60KxfUE3.css +0 -1
  45. package/src/ui/public/assets/EditorView-Do6ivdLH.js +0 -0
  46. package/src/ui/public/assets/FileDiffViewer-CXMo-Rez.js +0 -13
  47. package/src/ui/public/assets/SourceMapView-Cvp-7LtP.js +0 -3
  48. package/src/ui/public/assets/SourceMapView-DTsSdcgG.css +0 -1
  49. package/src/ui/public/assets/WorkbenchView-BDYbbkM1.js +0 -12
  50. package/src/ui/public/assets/WorkbenchView-zUE7sv6z.css +0 -1
  51. package/src/ui/public/assets/index-B666KMk3.js +0 -7
  52. 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
- console.warn(chalk.yellow('⚠️ 配置文件为空,已取消写入以避免覆盖。'));
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
- console.warn(chalk.yellow('⚠️ 配置文件为空,已取消写入以避免覆盖。'));
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
- console.warn(chalk.yellow('⚠️ 解析配置文件失败,已取消写入以避免覆盖。'));
195
- console.warn(chalk.gray(`文件: ${configPath}`));
196
- return;
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
- await saveConfig(config);
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
- await saveConfig(config);
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
- console.warn(chalk.yellow('⚠️ 解析配置文件失败,已取消写入最近目录以避免覆盖。'));
300
- console.warn(chalk.gray(`文件: ${configPath}`));
301
- return [];
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
- // 规范化:Windows 下统一为小写,去掉重复,保持最新在前
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(dirPath);
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
- await saveConfig(currentConfig);
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 = boxen(message, {
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
  }