@icode-js/icode 3.0.2

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 (46) hide show
  1. package/README.md +346 -0
  2. package/bin/icode.js +6 -0
  3. package/package.json +34 -0
  4. package/src/cli.js +131 -0
  5. package/src/commands/ai.js +287 -0
  6. package/src/commands/checkout.js +59 -0
  7. package/src/commands/clean.js +65 -0
  8. package/src/commands/codereview.js +52 -0
  9. package/src/commands/config.js +513 -0
  10. package/src/commands/explain.js +80 -0
  11. package/src/commands/help.js +49 -0
  12. package/src/commands/info.js +57 -0
  13. package/src/commands/migrate.js +86 -0
  14. package/src/commands/push.js +125 -0
  15. package/src/commands/sync.js +74 -0
  16. package/src/commands/tag.js +53 -0
  17. package/src/commands/undo.js +66 -0
  18. package/src/core/ai-client.js +1125 -0
  19. package/src/core/ai-commit-summary.js +18 -0
  20. package/src/core/ai-config.js +342 -0
  21. package/src/core/ai-diff-range.js +117 -0
  22. package/src/core/args.js +47 -0
  23. package/src/core/commit-conventions.js +169 -0
  24. package/src/core/config-store.js +194 -0
  25. package/src/core/errors.js +25 -0
  26. package/src/core/git-context.js +105 -0
  27. package/src/core/git-service.js +428 -0
  28. package/src/core/hook-diagnostics.js +23 -0
  29. package/src/core/loading.js +36 -0
  30. package/src/core/logger.js +55 -0
  31. package/src/core/prompts.js +152 -0
  32. package/src/core/shell.js +77 -0
  33. package/src/workflows/ai-codereview-workflow.js +126 -0
  34. package/src/workflows/ai-commit-workflow.js +128 -0
  35. package/src/workflows/ai-conflict-workflow.js +102 -0
  36. package/src/workflows/ai-explain-workflow.js +116 -0
  37. package/src/workflows/ai-risk-review-workflow.js +49 -0
  38. package/src/workflows/checkout-workflow.js +85 -0
  39. package/src/workflows/clean-workflow.js +131 -0
  40. package/src/workflows/info-workflow.js +30 -0
  41. package/src/workflows/migrate-workflow.js +449 -0
  42. package/src/workflows/push-workflow.js +276 -0
  43. package/src/workflows/rollback-workflow.js +84 -0
  44. package/src/workflows/sync-workflow.js +141 -0
  45. package/src/workflows/tag-workflow.js +64 -0
  46. package/src/workflows/undo-workflow.js +328 -0
@@ -0,0 +1,428 @@
1
+ import { IcodeError } from './errors.js'
2
+ import { buildHookHint, detectHookFailure } from './hook-diagnostics.js'
3
+ import { logger } from './logger.js'
4
+ import { runCommand } from './shell.js'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+
8
+ function cleanOutput(text) {
9
+ return (text || '').trim()
10
+ }
11
+
12
+ export class GitService {
13
+ constructor(context) {
14
+ this.context = context
15
+ this.cwd = context.topLevelPath
16
+ }
17
+
18
+ async exec(args, options = {}) {
19
+ logger.debug(`git ${args.join(' ')}`)
20
+ return runCommand('git', args, { cwd: this.cwd, ...options })
21
+ }
22
+
23
+ async getCurrentBranch() {
24
+ const result = await this.exec(['branch', '--show-current'], { allowFailure: true })
25
+ return cleanOutput(result.stdout)
26
+ }
27
+
28
+ async revParseShort(ref = 'HEAD') {
29
+ const result = await this.exec(['rev-parse', '--short', ref], { allowFailure: true })
30
+ return cleanOutput(result.stdout)
31
+ }
32
+
33
+ async fetch() {
34
+ await this.exec(['fetch', '--all', '--prune'])
35
+ }
36
+
37
+ operationFileExists(fileName) {
38
+ const candidatePaths = [
39
+ path.resolve(this.context.gitDir, fileName),
40
+ path.resolve(this.context.commonDir, fileName)
41
+ ]
42
+
43
+ return candidatePaths.some((candidatePath) => fs.existsSync(candidatePath))
44
+ }
45
+
46
+ async getInProgressOperation() {
47
+ if (this.operationFileExists('REVERT_HEAD')) {
48
+ return 'revert'
49
+ }
50
+
51
+ if (this.operationFileExists('CHERRY_PICK_HEAD')) {
52
+ return 'cherry-pick'
53
+ }
54
+
55
+ if (
56
+ this.operationFileExists('REBASE_HEAD') ||
57
+ this.operationFileExists('rebase-apply') ||
58
+ this.operationFileExists('rebase-merge')
59
+ ) {
60
+ return 'rebase'
61
+ }
62
+
63
+ return null
64
+ }
65
+
66
+ async branchExistsLocal(branchName) {
67
+ const result = await this.exec(['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], {
68
+ allowFailure: true
69
+ })
70
+ return result.exitCode === 0
71
+ }
72
+
73
+ async branchExistsRemote(branchName) {
74
+ const result = await this.exec(['ls-remote', '--exit-code', '--heads', 'origin', branchName], {
75
+ allowFailure: true
76
+ })
77
+ return result.exitCode === 0
78
+ }
79
+
80
+ async listLocalBranches() {
81
+ const result = await this.exec(['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
82
+ return result.stdout
83
+ .split('\n')
84
+ .map((item) => item.trim())
85
+ .filter(Boolean)
86
+ }
87
+
88
+ async listRemoteBranches(remoteName = 'origin') {
89
+ const result = await this.exec(['for-each-ref', '--format=%(refname:strip=3)', `refs/remotes/${remoteName}`], {
90
+ allowFailure: true
91
+ })
92
+
93
+ if (result.exitCode !== 0) {
94
+ return []
95
+ }
96
+
97
+ return result.stdout
98
+ .split('\n')
99
+ .map((item) => item.trim())
100
+ .filter((item) => item && item !== 'HEAD')
101
+ }
102
+
103
+ async listMergedLocalBranches(targetRef) {
104
+ const result = await this.exec(['branch', '--merged', targetRef])
105
+ return result.stdout
106
+ .split('\n')
107
+ .map((item) => item.replace('*', '').trim())
108
+ .filter(Boolean)
109
+ }
110
+
111
+ async checkout(branchName) {
112
+ await this.exec(['checkout', branchName])
113
+ }
114
+
115
+ async checkoutTracking(branchName) {
116
+ await this.exec(['checkout', '-b', branchName, '--track', `origin/${branchName}`])
117
+ }
118
+
119
+ async checkoutNewBranch(branchName, fromBranch) {
120
+ await this.exec(['checkout', '-b', branchName, fromBranch])
121
+ }
122
+
123
+ async createTrackingFromRemote(branchName) {
124
+ await this.exec(['branch', '--track', branchName, `origin/${branchName}`])
125
+ }
126
+
127
+ async pull(branchName, options = {}) {
128
+ const args = ['pull', 'origin', branchName]
129
+
130
+ if (options.noRebase !== false) {
131
+ args.push('--no-rebase')
132
+ }
133
+
134
+ if (options.ffOnly) {
135
+ args.push('--ff-only')
136
+ }
137
+
138
+ if (options.allowUnrelatedHistories) {
139
+ args.push('--allow-unrelated-histories')
140
+ }
141
+
142
+ await this.exec(args)
143
+ }
144
+
145
+ async merge(fromBranch, options = {}) {
146
+ const args = ['merge', fromBranch]
147
+ if (options.noFf !== false) {
148
+ args.push('--no-ff')
149
+ }
150
+ if (options.noEdit !== false) {
151
+ args.push('--no-edit')
152
+ }
153
+ await this.exec(args)
154
+ }
155
+
156
+ async rebase(fromRef) {
157
+ await this.exec(['rebase', fromRef])
158
+ }
159
+
160
+ async rebaseAbort() {
161
+ await this.exec(['rebase', '--abort'], { allowFailure: true })
162
+ }
163
+
164
+ async statusPorcelain() {
165
+ const result = await this.exec(['status', '--porcelain'])
166
+ return result.stdout
167
+ }
168
+
169
+ async hasChanges() {
170
+ const output = await this.statusPorcelain()
171
+ return cleanOutput(output).length > 0
172
+ }
173
+
174
+ async diffWorkingTree() {
175
+ const result = await this.exec(['diff'])
176
+ return result.stdout
177
+ }
178
+
179
+ async diffStaged() {
180
+ const result = await this.exec(['diff', '--staged'])
181
+ return result.stdout
182
+ }
183
+
184
+ async diffStagedStat() {
185
+ const result = await this.exec(['diff', '--staged', '--stat'])
186
+ return result.stdout
187
+ }
188
+
189
+ async diffStagedNameStatus() {
190
+ const result = await this.exec(['diff', '--staged', '--name-status'])
191
+ return result.stdout
192
+ }
193
+
194
+ async diffStat(rangeSpec = '') {
195
+ const args = ['diff', '--stat']
196
+ if (rangeSpec) {
197
+ args.push(rangeSpec)
198
+ }
199
+ const result = await this.exec(args)
200
+ return result.stdout
201
+ }
202
+
203
+ async diffNameStatus(rangeSpec = '') {
204
+ const args = ['diff', '--name-status']
205
+ if (rangeSpec) {
206
+ args.push(rangeSpec)
207
+ }
208
+ const result = await this.exec(args)
209
+ return result.stdout
210
+ }
211
+
212
+ async diffBetween(baseRef, headRef, options = {}) {
213
+ const style = options.style === 'two-dot' ? '..' : '...'
214
+ const rangeSpec = `${baseRef}${style}${headRef}`
215
+ const args = ['diff', rangeSpec]
216
+ const result = await this.exec(args)
217
+ return {
218
+ rangeSpec,
219
+ diff: result.stdout
220
+ }
221
+ }
222
+
223
+ async logOneline(rangeSpec = '', limit = 30) {
224
+ const args = ['log', '--oneline', `-${limit}`]
225
+ if (rangeSpec) {
226
+ args.push(rangeSpec)
227
+ }
228
+ const result = await this.exec(args, { allowFailure: true })
229
+ return result.stdout
230
+ }
231
+
232
+ async showCommitSummary(ref) {
233
+ const result = await this.exec(['show', '-s', '--format=%h %s', ref], { allowFailure: true })
234
+ return cleanOutput(result.stdout)
235
+ }
236
+
237
+ async listConflictedFiles() {
238
+ const result = await this.exec(['diff', '--name-only', '--diff-filter=U'])
239
+ return result.stdout
240
+ .split('\n')
241
+ .map((item) => item.trim())
242
+ .filter(Boolean)
243
+ }
244
+
245
+ async showFile(filePath) {
246
+ const result = await this.exec(['show', `:${filePath}`], { allowFailure: true })
247
+ return result.stdout || ''
248
+ }
249
+
250
+ async stageAll() {
251
+ await this.exec(['add', '-A'])
252
+ }
253
+
254
+ async commit(message, options = {}) {
255
+ if (!message || !message.trim()) {
256
+ throw new IcodeError('提交信息不能为空,请使用 -m 或 --message 指定。', {
257
+ code: 'COMMIT_MESSAGE_REQUIRED',
258
+ exitCode: 2
259
+ })
260
+ }
261
+
262
+ const args = ['commit', '-m', message]
263
+ if (options.noVerify) {
264
+ args.push('--no-verify')
265
+ }
266
+
267
+ try {
268
+ await this.exec(args)
269
+ } catch (error) {
270
+ // 统一识别 husky / hooks 拦截,给出明确可执行的提示。
271
+ if (detectHookFailure(error)) {
272
+ throw new IcodeError(buildHookHint('git commit'), {
273
+ code: 'HOOK_COMMIT_BLOCKED',
274
+ cause: error,
275
+ meta: error.meta
276
+ })
277
+ }
278
+ throw error
279
+ }
280
+ }
281
+
282
+ async push(branchName, options = {}) {
283
+ const args = ['push']
284
+
285
+ if (options.noVerify) {
286
+ args.push('--no-verify')
287
+ }
288
+
289
+ args.push('origin', branchName)
290
+
291
+ if (options.setUpstream) {
292
+ args.push('--set-upstream')
293
+ }
294
+
295
+ try {
296
+ await this.exec(args)
297
+ } catch (error) {
298
+ // push 同样可能被 pre-push 等 hook 拦截,这里统一转成可读错误。
299
+ if (detectHookFailure(error)) {
300
+ throw new IcodeError(buildHookHint('git push'), {
301
+ code: 'HOOK_PUSH_BLOCKED',
302
+ cause: error,
303
+ meta: error.meta
304
+ })
305
+ }
306
+ throw error
307
+ }
308
+ }
309
+
310
+ async pushRefspec(sourceBranch, targetBranch, options = {}) {
311
+ const args = ['push']
312
+
313
+ if (options.noVerify) {
314
+ args.push('--no-verify')
315
+ }
316
+
317
+ args.push('origin', `${sourceBranch}:${targetBranch}`)
318
+
319
+ try {
320
+ await this.exec(args)
321
+ } catch (error) {
322
+ if (detectHookFailure(error)) {
323
+ throw new IcodeError(buildHookHint('git push(refspec)'), {
324
+ code: 'HOOK_PUSH_REFSPEC_BLOCKED',
325
+ cause: error,
326
+ meta: error.meta
327
+ })
328
+ }
329
+ throw error
330
+ }
331
+ }
332
+
333
+ async listTags(pattern = '*') {
334
+ const result = await this.exec(['tag', '--list', pattern])
335
+ return result.stdout
336
+ .split('\n')
337
+ .map((item) => item.trim())
338
+ .filter(Boolean)
339
+ }
340
+
341
+ async createAnnotatedTag(tagName, message, fromRef) {
342
+ const args = ['tag', '-a', tagName, '-m', message]
343
+ if (fromRef) {
344
+ args.push(fromRef)
345
+ }
346
+ await this.exec(args)
347
+ }
348
+
349
+ async pushTag(tagName, options = {}) {
350
+ const args = ['push']
351
+ if (options.noVerify) {
352
+ args.push('--no-verify')
353
+ }
354
+ args.push('origin', tagName)
355
+
356
+ try {
357
+ await this.exec(args)
358
+ } catch (error) {
359
+ if (detectHookFailure(error)) {
360
+ throw new IcodeError(buildHookHint('git push(tag)'), {
361
+ code: 'HOOK_TAG_PUSH_BLOCKED',
362
+ cause: error,
363
+ meta: error.meta
364
+ })
365
+ }
366
+ throw error
367
+ }
368
+ }
369
+
370
+ async reset(mode, ref = 'HEAD~1') {
371
+ const validModes = new Set(['soft', 'mixed', 'hard'])
372
+ if (!validModes.has(mode)) {
373
+ throw new IcodeError(`不支持的 reset 模式: ${mode}`, {
374
+ code: 'RESET_MODE_INVALID',
375
+ exitCode: 2
376
+ })
377
+ }
378
+
379
+ await this.exec(['reset', `--${mode}`, ref])
380
+ }
381
+
382
+ async revert(ref = 'HEAD') {
383
+ await this.exec(['revert', '--no-edit', ref])
384
+ }
385
+
386
+ async revertContinue() {
387
+ await this.exec(['revert', '--continue'])
388
+ }
389
+
390
+ async revertAbort() {
391
+ await this.exec(['revert', '--abort'], { allowFailure: true })
392
+ }
393
+
394
+ async revList(rangeSpec) {
395
+ const result = await this.exec(['rev-list', '--reverse', rangeSpec])
396
+ return result.stdout
397
+ .split('\n')
398
+ .map((item) => item.trim())
399
+ .filter(Boolean)
400
+ }
401
+
402
+ async cherryPick(commits) {
403
+ const normalized = Array.isArray(commits) ? commits : [commits]
404
+ const list = normalized.map((item) => item.trim()).filter(Boolean)
405
+ if (!list.length) {
406
+ return
407
+ }
408
+
409
+ await this.exec(['cherry-pick', ...list])
410
+ }
411
+
412
+ async cherryPickContinue() {
413
+ await this.exec(['cherry-pick', '--continue'])
414
+ }
415
+
416
+ async abortCherryPick() {
417
+ await this.exec(['cherry-pick', '--abort'], { allowFailure: true })
418
+ }
419
+
420
+ async deleteLocalBranch(branchName, options = {}) {
421
+ const args = ['branch', options.force ? '-D' : '-d', branchName]
422
+ await this.exec(args)
423
+ }
424
+
425
+ async deleteRemoteBranch(branchName) {
426
+ await this.exec(['push', 'origin', '--delete', branchName])
427
+ }
428
+ }
@@ -0,0 +1,23 @@
1
+ const HOOK_PATTERNS = [
2
+ /husky/i,
3
+ /pre-commit/i,
4
+ /pre-push/i,
5
+ /commit-msg/i,
6
+ /hook failed/i,
7
+ /hook declined/i
8
+ ]
9
+
10
+ export function collectCommandOutput(error) {
11
+ const stdout = error?.meta?.stdout || ''
12
+ const stderr = error?.meta?.stderr || ''
13
+ return `${stdout}\n${stderr}`
14
+ }
15
+
16
+ export function detectHookFailure(error) {
17
+ const output = collectCommandOutput(error)
18
+ return HOOK_PATTERNS.some((pattern) => pattern.test(output))
19
+ }
20
+
21
+ export function buildHookHint(action) {
22
+ return `${action} 被 Git hooks/Husky 拦截。若需跳过校验请重试并加上 --no-verify。`
23
+ }
@@ -0,0 +1,36 @@
1
+ const SPINNER_FRAMES = ['-', '\\', '|', '/']
2
+
3
+ function canRenderSpinner() {
4
+ return Boolean(process.stderr.isTTY) && process.env.ICODE_NO_SPINNER !== '1'
5
+ }
6
+
7
+ function clearLine() {
8
+ if (!canRenderSpinner()) {
9
+ return
10
+ }
11
+
12
+ process.stderr.write('\r\x1b[2K')
13
+ }
14
+
15
+ export async function withSpinner(text, task) {
16
+ if (!canRenderSpinner()) {
17
+ return task()
18
+ }
19
+
20
+ let frameIndex = 0
21
+ const render = () => {
22
+ const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]
23
+ frameIndex += 1
24
+ process.stderr.write(`\r[icode] ${frame} ${text}`)
25
+ }
26
+
27
+ render()
28
+ const timer = setInterval(render, 80)
29
+
30
+ try {
31
+ return await task()
32
+ } finally {
33
+ clearInterval(timer)
34
+ clearLine()
35
+ }
36
+ }
@@ -0,0 +1,55 @@
1
+ const COLORS = {
2
+ reset: '\x1b[0m',
3
+ gray: '\x1b[90m',
4
+ green: '\x1b[32m',
5
+ yellow: '\x1b[33m',
6
+ red: '\x1b[31m',
7
+ cyan: '\x1b[36m'
8
+ }
9
+
10
+ let verboseEnabled = process.env.ICODE_LOG_LEVEL === 'verbose'
11
+
12
+ function colorize(color, message) {
13
+ return `${COLORS[color] || ''}${message}${COLORS.reset}`
14
+ }
15
+
16
+ // Prefix every rendered line so multiline logs keep the same visible marker.
17
+ function print(stream, level, message) {
18
+ const normalizedMessage = String(message ?? '')
19
+ const formattedMessage = normalizedMessage
20
+ .split('\n')
21
+ .map((line) => `${level}${line}`)
22
+ .join('\n')
23
+
24
+ stream.write(`${formattedMessage}\n`)
25
+ }
26
+
27
+ export const logger = {
28
+ setVerbose(enabled) {
29
+ verboseEnabled = Boolean(enabled)
30
+ },
31
+
32
+ info(message) {
33
+ print(process.stdout, colorize('cyan', '[icode] '), `${message}`)
34
+ },
35
+
36
+ success(message) {
37
+ print(process.stdout, colorize('green', '[icode] '), `${message}`)
38
+ },
39
+
40
+ warn(message) {
41
+ print(process.stderr, colorize('yellow', '[icode] '), `${message}`)
42
+ },
43
+
44
+ error(message) {
45
+ print(process.stderr, colorize('red', '[icode] '), `${message}`)
46
+ },
47
+
48
+ debug(message) {
49
+ if (!verboseEnabled) {
50
+ return
51
+ }
52
+
53
+ print(process.stdout, colorize('gray', '[icode:debug] '), `${message}`)
54
+ }
55
+ }
@@ -0,0 +1,152 @@
1
+ import { createInterface } from 'node:readline/promises'
2
+ import { stdin, stdout } from 'node:process'
3
+
4
+ function isInteractive() {
5
+ return Boolean(stdin.isTTY && stdout.isTTY)
6
+ }
7
+
8
+ export function isInteractiveTerminal() {
9
+ return isInteractive()
10
+ }
11
+
12
+ export async function confirm(message, defaultValue = true) {
13
+ if (!isInteractive()) {
14
+ return defaultValue
15
+ }
16
+
17
+ const rl = createInterface({ input: stdin, output: stdout })
18
+ const suffix = defaultValue ? '[Y/n]' : '[y/N]'
19
+ const answer = (await rl.question(`${message} ${suffix} `)).trim().toLowerCase()
20
+ rl.close()
21
+
22
+ if (!answer) {
23
+ return defaultValue
24
+ }
25
+
26
+ if (['y', 'yes'].includes(answer)) {
27
+ return true
28
+ }
29
+
30
+ if (['n', 'no'].includes(answer)) {
31
+ return false
32
+ }
33
+
34
+ return defaultValue
35
+ }
36
+
37
+ export async function input(message, defaultValue = '') {
38
+ if (!isInteractive()) {
39
+ return defaultValue
40
+ }
41
+
42
+ const rl = createInterface({ input: stdin, output: stdout })
43
+ const answer = await rl.question(`${message}${defaultValue ? ` (${defaultValue})` : ''}: `)
44
+ rl.close()
45
+ const normalized = answer.trim()
46
+ return normalized || defaultValue
47
+ }
48
+
49
+ export async function chooseOne(message, choices, defaultIndex = 0) {
50
+ if (!Array.isArray(choices) || choices.length === 0) {
51
+ throw new Error('chooseOne 需要至少一个可选项')
52
+ }
53
+
54
+ const safeDefaultIndex = Math.min(Math.max(defaultIndex, 0), choices.length - 1)
55
+ if (!isInteractive()) {
56
+ return choices[safeDefaultIndex].value
57
+ }
58
+
59
+ stdout.write(`${message}\n`)
60
+ choices.forEach((choice, index) => {
61
+ stdout.write(` ${index + 1}. ${choice.label}\n`)
62
+ })
63
+
64
+ const rl = createInterface({ input: stdin, output: stdout })
65
+ const answer = (await rl.question(`请选择 [${safeDefaultIndex + 1}]: `)).trim()
66
+ rl.close()
67
+
68
+ if (!answer) {
69
+ return choices[safeDefaultIndex].value
70
+ }
71
+
72
+ const numeric = Number(answer)
73
+ if (!Number.isInteger(numeric) || numeric < 1 || numeric > choices.length) {
74
+ return choices[safeDefaultIndex].value
75
+ }
76
+
77
+ return choices[numeric - 1].value
78
+ }
79
+
80
+ export async function chooseMany(message, choices, options = {}) {
81
+ if (!Array.isArray(choices) || choices.length === 0) {
82
+ throw new Error('chooseMany 需要至少一个可选项')
83
+ }
84
+
85
+ const parsedMin = Number(options.minSelections ?? 0)
86
+ const parsedMax = Number(options.maxSelections ?? choices.length)
87
+ const minSelections = Number.isFinite(parsedMin) ? Math.max(0, Math.floor(parsedMin)) : 0
88
+ const maxCap = Number.isFinite(parsedMax) ? Math.max(0, Math.floor(parsedMax)) : choices.length
89
+ const maxSelections = Math.max(minSelections, Math.min(choices.length, maxCap))
90
+ const doneLabel = options.doneLabel || '完成选择'
91
+ const cancelLabel = options.cancelLabel || '取消'
92
+ const defaultValues = Array.isArray(options.defaultValues) ? options.defaultValues : []
93
+ const defaultSet = new Set(defaultValues)
94
+ const selectedValues = choices
95
+ .map((choice) => choice.value)
96
+ .filter((value) => defaultSet.has(value))
97
+ .slice(0, maxSelections)
98
+
99
+ if (!isInteractive()) {
100
+ return selectedValues
101
+ }
102
+
103
+ const selected = new Set(selectedValues)
104
+ const doneValue = '__prompt_done__'
105
+ const cancelValue = '__prompt_cancel__'
106
+
107
+ while (true) {
108
+ const menuChoices = choices.map((choice) => ({
109
+ value: choice.value,
110
+ label: `${selected.has(choice.value) ? '[x]' : '[ ]'} ${choice.label}`
111
+ }))
112
+ menuChoices.push({
113
+ value: doneValue,
114
+ label: `${doneLabel}(已选 ${selected.size} 项)`
115
+ })
116
+ menuChoices.push({
117
+ value: cancelValue,
118
+ label: cancelLabel
119
+ })
120
+
121
+ const defaultChoiceValue = selected.size >= minSelections ? doneValue : choices[0].value
122
+ const defaultIndex = Math.max(0, menuChoices.findIndex((item) => item.value === defaultChoiceValue))
123
+ const selectedAction = await chooseOne(`${message}`, menuChoices, defaultIndex)
124
+
125
+ if (selectedAction === cancelValue) {
126
+ return null
127
+ }
128
+
129
+ if (selectedAction === doneValue) {
130
+ if (selected.size < minSelections) {
131
+ stdout.write(`至少需要选择 ${minSelections} 项。\n`)
132
+ continue
133
+ }
134
+ return choices
135
+ .map((choice) => choice.value)
136
+ .filter((value) => selected.has(value))
137
+ .slice(0, maxSelections)
138
+ }
139
+
140
+ if (selected.has(selectedAction)) {
141
+ selected.delete(selectedAction)
142
+ continue
143
+ }
144
+
145
+ if (selected.size >= maxSelections) {
146
+ stdout.write(`最多只能选择 ${maxSelections} 项。\n`)
147
+ continue
148
+ }
149
+
150
+ selected.add(selectedAction)
151
+ }
152
+ }