@icode-js/icode 3.1.0 → 3.2.0
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 +105 -5
- package/package.json +1 -1
- package/src/cli.js +6 -0
- package/src/commands/clean.js +4 -1
- package/src/commands/codereview.js +4 -1
- package/src/commands/completion.js +9 -6
- package/src/commands/config.js +9 -6
- package/src/commands/doctor.js +80 -0
- package/src/commands/explain.js +4 -1
- package/src/commands/help.js +10 -0
- package/src/commands/info.js +4 -1
- package/src/commands/migrate.js +38 -1
- package/src/commands/push.js +45 -18
- package/src/commands/ready.js +110 -0
- package/src/commands/save.js +98 -0
- package/src/commands/undo.js +13 -1
- package/src/core/ai/diff-prompt.js +204 -0
- package/src/core/cli/args.js +23 -2
- package/src/core/cli/completion.js +33 -0
- package/src/core/config/store.js +2 -4
- package/src/core/git/context.js +14 -15
- package/src/core/git/preconditions.js +88 -0
- package/src/core/git/service.js +151 -1
- package/src/core/remote-merge/providers/gitlab.js +149 -90
- package/src/core/tools/interactive.js +209 -34
- package/src/core/tools/loading.js +5 -1
- package/src/core/tools/logger.js +5 -0
- package/src/workflows/ai-codereview-workflow.js +14 -2
- package/src/workflows/ai-commit-workflow.js +14 -6
- package/src/workflows/checkout-workflow.js +14 -2
- package/src/workflows/clean-workflow.js +87 -70
- package/src/workflows/doctor-workflow.js +131 -0
- package/src/workflows/migrate-workflow.js +265 -58
- package/src/workflows/push-workflow.js +232 -50
- package/src/workflows/ready-workflow.js +278 -0
- package/src/workflows/rollback-workflow.js +20 -5
- package/src/workflows/save-workflow.js +152 -0
- package/src/workflows/sync-workflow.js +18 -2
- package/src/workflows/undo-workflow.js +25 -7
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { IcodeError } from '../../errors.js'
|
|
2
|
+
import { withSpinner } from '../../tools/loading.js'
|
|
3
|
+
import { logger } from '../../tools/logger.js'
|
|
2
4
|
import { normalizeRemoteMergeReason, parseRemoteMergePayload } from '../shared.js'
|
|
3
5
|
|
|
4
6
|
const GITLAB_MERGE_STATUS_POLL_INTERVAL_MS = 1000
|
|
7
|
+
const GITLAB_MERGE_STATUS_MAX_ATTEMPTS = 60
|
|
5
8
|
const GITLAB_PENDING_MERGE_STATUSES = new Set([
|
|
6
9
|
'unchecked',
|
|
7
10
|
'checking',
|
|
@@ -19,6 +22,15 @@ const GITLAB_ACCEPTED_MERGE_STATUSES = new Set([
|
|
|
19
22
|
'merge_time'
|
|
20
23
|
])
|
|
21
24
|
|
|
25
|
+
// 生成可读的 GitLab MR 标签,输入 MR id 与 URL,输出适合日志展示的短文本。
|
|
26
|
+
function formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl) {
|
|
27
|
+
if (mergeRequestUrl) {
|
|
28
|
+
return `${logger.color('green', mergeRequestUrl)} (#${mergeRequestId})`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return `#${mergeRequestId}`
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
// 根据 Git remote URL 生成标准 GitLab 项目 URL,无法识别时返回空串。
|
|
23
35
|
export function resolveGitLabProjectUrlFromRemote(remoteUrl = '') {
|
|
24
36
|
const raw = String(remoteUrl || '').trim()
|
|
@@ -121,6 +133,24 @@ function delay(ms) {
|
|
|
121
133
|
})
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
// 合并检查轮询间隔,测试可显式传入 0 以避免等待;输入 options,输出毫秒数。
|
|
137
|
+
function resolvePollIntervalMs(options = {}) {
|
|
138
|
+
if (Number.isInteger(options.pollIntervalMs) && options.pollIntervalMs >= 0) {
|
|
139
|
+
return options.pollIntervalMs
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return GITLAB_MERGE_STATUS_POLL_INTERVAL_MS
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 合并检查最大轮询次数,测试或调用方可显式缩短;输入 options,输出正整数次数。
|
|
146
|
+
function resolveMaxPollAttempts(options = {}) {
|
|
147
|
+
if (Number.isInteger(options.maxPollAttempts) && options.maxPollAttempts > 0) {
|
|
148
|
+
return options.maxPollAttempts
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return GITLAB_MERGE_STATUS_MAX_ATTEMPTS
|
|
152
|
+
}
|
|
153
|
+
|
|
124
154
|
// 执行一次 GitLab JSON 请求,并统一返回响应对象、原始文本和解析后的负载。
|
|
125
155
|
async function performGitLabJsonRequest(endpoint, requestOptions, errorMessage) {
|
|
126
156
|
let response
|
|
@@ -180,6 +210,27 @@ async function fetchGitLabMergeRequest(project, mergeRequestId, apiKey) {
|
|
|
180
210
|
}
|
|
181
211
|
}
|
|
182
212
|
|
|
213
|
+
// 构建 GitLab 状态轮询超时结果,输入阶段信息和最后一次响应,输出统一失败结构。
|
|
214
|
+
function buildGitLabPollTimeoutResult(stage, mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload) {
|
|
215
|
+
const status = typeof lastPayload?.detailed_merge_status === 'string' && lastPayload.detailed_merge_status.trim()
|
|
216
|
+
? lastPayload.detailed_merge_status.trim()
|
|
217
|
+
: (typeof lastPayload?.merge_status === 'string' ? lastPayload.merge_status.trim() : '')
|
|
218
|
+
const reason = status
|
|
219
|
+
? `GitLab 合并检查超时,最后状态: ${status},已轮询 ${maxAttempts} 次。`
|
|
220
|
+
: `GitLab 合并检查超时,已轮询 ${maxAttempts} 次。`
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
conflict: false,
|
|
225
|
+
status: 408,
|
|
226
|
+
reason,
|
|
227
|
+
step: stage,
|
|
228
|
+
mergeRequestId,
|
|
229
|
+
mergeRequestUrl,
|
|
230
|
+
payload: lastPayload
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
183
234
|
// 将 GitLab MR 状态转换成统一结果,保证上层能明确识别冲突分支。
|
|
184
235
|
function buildGitLabMergeStatusResult(payload, mergeRequestId, mergeRequestUrl) {
|
|
185
236
|
const detailedMergeStatus = typeof payload?.detailed_merge_status === 'string'
|
|
@@ -331,61 +382,97 @@ function buildGitLabMergeReadinessResult(payload, mergeRequestId, mergeRequestUr
|
|
|
331
382
|
}
|
|
332
383
|
|
|
333
384
|
// 轮询 GitLab MR,直到“是否允许 merge”检查完成。
|
|
334
|
-
async function waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
385
|
+
async function waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey, options = {}) {
|
|
386
|
+
const maxAttempts = resolveMaxPollAttempts(options)
|
|
387
|
+
const pollIntervalMs = resolvePollIntervalMs(options)
|
|
388
|
+
let lastPayload = null
|
|
389
|
+
|
|
390
|
+
const pollResult = await withSpinner(
|
|
391
|
+
`等待 GitLab 完成合并前检查 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
|
|
392
|
+
async () => {
|
|
393
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
394
|
+
const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
|
|
395
|
+
lastPayload = mergeRequest.payload
|
|
396
|
+
if (!mergeRequest.response.ok) {
|
|
397
|
+
return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
|
|
398
|
+
step: 'inspect',
|
|
399
|
+
mergeRequestId,
|
|
400
|
+
mergeRequestUrl,
|
|
401
|
+
payload: {
|
|
402
|
+
create: null,
|
|
403
|
+
merge: null,
|
|
404
|
+
inspect: mergeRequest.payload
|
|
405
|
+
}
|
|
406
|
+
})
|
|
347
407
|
}
|
|
348
|
-
})
|
|
349
|
-
}
|
|
350
408
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
409
|
+
const result = buildGitLabMergeReadinessResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
|
|
410
|
+
if (result) {
|
|
411
|
+
return result
|
|
412
|
+
}
|
|
355
413
|
|
|
356
|
-
|
|
414
|
+
if (attempt < maxAttempts) {
|
|
415
|
+
await delay(pollIntervalMs)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
if (pollResult) {
|
|
423
|
+
return pollResult
|
|
357
424
|
}
|
|
425
|
+
|
|
426
|
+
return buildGitLabPollTimeoutResult('inspect', mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload)
|
|
358
427
|
}
|
|
359
428
|
|
|
360
429
|
// 轮询 GitLab MR,直到异步冲突检查完成并得到可归档的最终状态。
|
|
361
|
-
async function waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
430
|
+
async function waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey, options = {}) {
|
|
431
|
+
const maxAttempts = resolveMaxPollAttempts(options)
|
|
432
|
+
const pollIntervalMs = resolvePollIntervalMs(options)
|
|
433
|
+
let lastPayload = null
|
|
434
|
+
|
|
435
|
+
const pollResult = await withSpinner(
|
|
436
|
+
`等待 GitLab 完成远程合并 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
|
|
437
|
+
async () => {
|
|
438
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
439
|
+
const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
|
|
440
|
+
lastPayload = mergeRequest.payload
|
|
441
|
+
if (!mergeRequest.response.ok) {
|
|
442
|
+
return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
|
|
443
|
+
step: 'inspect',
|
|
444
|
+
mergeRequestId,
|
|
445
|
+
mergeRequestUrl,
|
|
446
|
+
payload: {
|
|
447
|
+
create: null,
|
|
448
|
+
merge: null,
|
|
449
|
+
inspect: mergeRequest.payload
|
|
450
|
+
}
|
|
451
|
+
})
|
|
374
452
|
}
|
|
375
|
-
})
|
|
376
|
-
}
|
|
377
453
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
454
|
+
const result = buildGitLabMergeStatusResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
|
|
455
|
+
if (result) {
|
|
456
|
+
return result
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (attempt < maxAttempts) {
|
|
460
|
+
await delay(pollIntervalMs)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
382
463
|
|
|
383
|
-
|
|
464
|
+
return null
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
if (pollResult) {
|
|
468
|
+
return pollResult
|
|
384
469
|
}
|
|
470
|
+
|
|
471
|
+
return buildGitLabPollTimeoutResult('inspect', mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload)
|
|
385
472
|
}
|
|
386
473
|
|
|
387
|
-
// 根据创建 MR
|
|
388
|
-
async function processGitLabMergeRequest(project, createPayload, apiKey) {
|
|
474
|
+
// 根据创建 MR 的结果提交自动合并,由 GitLab merge 接口按项目审批规则判定是否允许合并。
|
|
475
|
+
async function processGitLabMergeRequest(project, createPayload, apiKey, options = {}) {
|
|
389
476
|
const mergeRequestId = createPayload?.iid ? String(createPayload.iid) : ''
|
|
390
477
|
const mergeRequestUrl = typeof createPayload?.web_url === 'string' ? createPayload.web_url : ''
|
|
391
478
|
|
|
@@ -400,56 +487,26 @@ async function processGitLabMergeRequest(project, createPayload, apiKey) {
|
|
|
400
487
|
mergeRequestUrl,
|
|
401
488
|
payload: {
|
|
402
489
|
create: createPayload,
|
|
490
|
+
inspect: null,
|
|
403
491
|
merge: null
|
|
404
492
|
}
|
|
405
493
|
}
|
|
406
494
|
}
|
|
407
495
|
|
|
408
496
|
const sourceSha = typeof createPayload?.sha === 'string' ? createPayload.sha.trim() : ''
|
|
409
|
-
|
|
497
|
+
logger.success(`GitLab 合并请求已创建: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
|
|
498
|
+
const readinessResult = await waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey, options)
|
|
410
499
|
if (!readinessResult.ok) {
|
|
411
500
|
return {
|
|
412
501
|
...readinessResult,
|
|
413
502
|
payload: {
|
|
414
503
|
create: createPayload,
|
|
415
|
-
approve: null,
|
|
416
504
|
merge: null,
|
|
417
505
|
inspect: readinessResult.payload
|
|
418
506
|
}
|
|
419
507
|
}
|
|
420
508
|
}
|
|
421
|
-
|
|
422
|
-
const approveRequestBody = {}
|
|
423
|
-
if (sourceSha) {
|
|
424
|
-
approveRequestBody.sha = sourceSha
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const approveEndpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}/approve`
|
|
428
|
-
const approveResult = await performGitLabJsonRequest(
|
|
429
|
-
approveEndpoint,
|
|
430
|
-
{
|
|
431
|
-
method: 'POST',
|
|
432
|
-
headers: {
|
|
433
|
-
'Content-Type': 'application/json',
|
|
434
|
-
'PRIVATE-TOKEN': apiKey
|
|
435
|
-
},
|
|
436
|
-
body: JSON.stringify(approveRequestBody)
|
|
437
|
-
},
|
|
438
|
-
'GitLab 合并审批请求失败'
|
|
439
|
-
)
|
|
440
|
-
|
|
441
|
-
if (!approveResult.response.ok) {
|
|
442
|
-
return buildGitLabFailedResult(approveResult.response, approveResult.rawText, approveResult.payload, {
|
|
443
|
-
step: 'approve',
|
|
444
|
-
mergeRequestId,
|
|
445
|
-
mergeRequestUrl,
|
|
446
|
-
payload: {
|
|
447
|
-
create: createPayload,
|
|
448
|
-
approve: approveResult.payload,
|
|
449
|
-
merge: null
|
|
450
|
-
}
|
|
451
|
-
})
|
|
452
|
-
}
|
|
509
|
+
logger.success(`GitLab 合并前检查通过: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
|
|
453
510
|
|
|
454
511
|
const mergeRequestBody = {
|
|
455
512
|
auto_merge: true
|
|
@@ -459,17 +516,20 @@ async function processGitLabMergeRequest(project, createPayload, apiKey) {
|
|
|
459
516
|
}
|
|
460
517
|
|
|
461
518
|
const mergeEndpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}/merge`
|
|
462
|
-
const mergeResult = await
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
519
|
+
const mergeResult = await withSpinner(
|
|
520
|
+
`提交 GitLab 自动合并 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
|
|
521
|
+
() => performGitLabJsonRequest(
|
|
522
|
+
mergeEndpoint,
|
|
523
|
+
{
|
|
524
|
+
method: 'PUT',
|
|
525
|
+
headers: {
|
|
526
|
+
'Content-Type': 'application/json',
|
|
527
|
+
'PRIVATE-TOKEN': apiKey
|
|
528
|
+
},
|
|
529
|
+
body: JSON.stringify(mergeRequestBody)
|
|
469
530
|
},
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
'GitLab 自动合并请求失败'
|
|
531
|
+
'GitLab 自动合并请求失败'
|
|
532
|
+
)
|
|
473
533
|
)
|
|
474
534
|
|
|
475
535
|
if (!mergeResult.response.ok) {
|
|
@@ -479,18 +539,17 @@ async function processGitLabMergeRequest(project, createPayload, apiKey) {
|
|
|
479
539
|
mergeRequestUrl,
|
|
480
540
|
payload: {
|
|
481
541
|
create: createPayload,
|
|
482
|
-
approve: approveResult.payload,
|
|
483
542
|
merge: mergeResult.payload
|
|
484
543
|
}
|
|
485
544
|
})
|
|
486
545
|
}
|
|
487
546
|
|
|
488
|
-
|
|
547
|
+
logger.success(`GitLab 自动合并已提交: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
|
|
548
|
+
const finalResult = await waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey, options)
|
|
489
549
|
return {
|
|
490
550
|
...finalResult,
|
|
491
551
|
payload: {
|
|
492
552
|
create: createPayload,
|
|
493
|
-
approve: approveResult.payload,
|
|
494
553
|
merge: mergeResult.payload,
|
|
495
554
|
inspect: finalResult.payload
|
|
496
555
|
}
|
|
@@ -533,5 +592,5 @@ export async function requestGitLabRemoteMerge(options = {}) {
|
|
|
533
592
|
})
|
|
534
593
|
}
|
|
535
594
|
|
|
536
|
-
return processGitLabMergeRequest(project, payload, apiKey)
|
|
595
|
+
return processGitLabMergeRequest(project, payload, apiKey, options)
|
|
537
596
|
}
|
|
@@ -115,33 +115,22 @@ export async function chooseOne(message, choices, defaultIndex = 0) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
118
|
+
* 按选项顺序输出已选 value。
|
|
119
|
+
* 输入为选项与已选集合,输出为稳定顺序的 value 数组。
|
|
120
120
|
*/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
throw new Error('chooseMany 需要至少一个可选项')
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const parsedMin = Number(options.minSelections ?? 0)
|
|
127
|
-
const parsedMax = Number(options.maxSelections ?? choices.length)
|
|
128
|
-
const minSelections = Number.isFinite(parsedMin) ? Math.max(0, Math.floor(parsedMin)) : 0
|
|
129
|
-
const maxCap = Number.isFinite(parsedMax) ? Math.max(0, Math.floor(parsedMax)) : choices.length
|
|
130
|
-
const maxSelections = Math.max(minSelections, Math.min(choices.length, maxCap))
|
|
131
|
-
const doneLabel = options.doneLabel || '完成选择'
|
|
132
|
-
const cancelLabel = options.cancelLabel || '取消'
|
|
133
|
-
const defaultValues = Array.isArray(options.defaultValues) ? options.defaultValues : []
|
|
134
|
-
const defaultSet = new Set(defaultValues)
|
|
135
|
-
const selectedValues = choices
|
|
121
|
+
function collectSelectedValues(choices, selected, maxSelections) {
|
|
122
|
+
return choices
|
|
136
123
|
.map((choice) => choice.value)
|
|
137
|
-
.filter((value) =>
|
|
124
|
+
.filter((value) => selected.has(value))
|
|
138
125
|
.slice(0, maxSelections)
|
|
126
|
+
}
|
|
139
127
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
128
|
+
/**
|
|
129
|
+
* 使用旧版数字菜单进行多选,供不支持 raw mode 的终端回退。
|
|
130
|
+
* 输入为多选上下文,输出为已选 value 数组或 null。
|
|
131
|
+
*/
|
|
132
|
+
async function chooseManyByNumberInput(message, choices, context) {
|
|
133
|
+
const selected = new Set(context.selectedValues)
|
|
145
134
|
const doneValue = '__prompt_done__'
|
|
146
135
|
const cancelValue = '__prompt_cancel__'
|
|
147
136
|
|
|
@@ -152,14 +141,14 @@ export async function chooseMany(message, choices, options = {}) {
|
|
|
152
141
|
}))
|
|
153
142
|
menuChoices.push({
|
|
154
143
|
value: doneValue,
|
|
155
|
-
label: `${doneLabel}(已选 ${selected.size} 项)`
|
|
144
|
+
label: `${context.doneLabel}(已选 ${selected.size} 项)`
|
|
156
145
|
})
|
|
157
146
|
menuChoices.push({
|
|
158
147
|
value: cancelValue,
|
|
159
|
-
label: cancelLabel
|
|
148
|
+
label: context.cancelLabel
|
|
160
149
|
})
|
|
161
150
|
|
|
162
|
-
const defaultChoiceValue = selected.size >= minSelections ? doneValue : choices[0].value
|
|
151
|
+
const defaultChoiceValue = selected.size >= context.minSelections ? doneValue : choices[0].value
|
|
163
152
|
const resolvedDefaultIndex = Math.max(0, menuChoices.findIndex((item) => item.value === defaultChoiceValue))
|
|
164
153
|
const selectedAction = await chooseOne(message, menuChoices, resolvedDefaultIndex)
|
|
165
154
|
|
|
@@ -168,14 +157,11 @@ export async function chooseMany(message, choices, options = {}) {
|
|
|
168
157
|
}
|
|
169
158
|
|
|
170
159
|
if (selectedAction === doneValue) {
|
|
171
|
-
if (selected.size < minSelections) {
|
|
172
|
-
stdout.write(`至少需要选择 ${minSelections} 项。\n`)
|
|
160
|
+
if (selected.size < context.minSelections) {
|
|
161
|
+
stdout.write(`至少需要选择 ${context.minSelections} 项。\n`)
|
|
173
162
|
continue
|
|
174
163
|
}
|
|
175
|
-
return choices
|
|
176
|
-
.map((choice) => choice.value)
|
|
177
|
-
.filter((value) => selected.has(value))
|
|
178
|
-
.slice(0, maxSelections)
|
|
164
|
+
return collectSelectedValues(choices, selected, context.maxSelections)
|
|
179
165
|
}
|
|
180
166
|
|
|
181
167
|
if (selected.has(selectedAction)) {
|
|
@@ -183,11 +169,200 @@ export async function chooseMany(message, choices, options = {}) {
|
|
|
183
169
|
continue
|
|
184
170
|
}
|
|
185
171
|
|
|
186
|
-
if (selected.size >= maxSelections) {
|
|
187
|
-
stdout.write(`最多只能选择 ${maxSelections} 项。\n`)
|
|
172
|
+
if (selected.size >= context.maxSelections) {
|
|
173
|
+
stdout.write(`最多只能选择 ${context.maxSelections} 项。\n`)
|
|
188
174
|
continue
|
|
189
175
|
}
|
|
190
176
|
|
|
191
177
|
selected.add(selectedAction)
|
|
192
178
|
}
|
|
193
179
|
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 渲染 checkbox 多选列表。
|
|
183
|
+
* 输入为当前光标和选择状态,输出为本次渲染占用的行数。
|
|
184
|
+
*/
|
|
185
|
+
function renderCheckboxChoices(message, choices, context, cursorIndex, selected, statusMessage, renderedLines) {
|
|
186
|
+
if (renderedLines > 0) {
|
|
187
|
+
stdout.write(`\x1b[${renderedLines}F`)
|
|
188
|
+
stdout.write('\x1b[J')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const lines = [
|
|
192
|
+
message,
|
|
193
|
+
`↑/↓ 移动,Space 选中/取消,Enter ${context.doneLabel},q ${context.cancelLabel}(已选 ${selected.size} 项)`
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
if (statusMessage) {
|
|
197
|
+
lines.push(statusMessage)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
choices.forEach((choice, index) => {
|
|
201
|
+
const cursor = index === cursorIndex ? '>' : ' '
|
|
202
|
+
const checkbox = selected.has(choice.value) ? '[x]' : '[ ]'
|
|
203
|
+
lines.push(`${cursor} ${checkbox} ${choice.label}`)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
stdout.write(`${lines.join('\n')}\n`)
|
|
207
|
+
return lines.length
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 使用键盘 checkbox 进行多选。
|
|
212
|
+
* 输入为多选上下文,输出为已选 value 数组或 null。
|
|
213
|
+
*/
|
|
214
|
+
function chooseManyByCheckbox(message, choices, context) {
|
|
215
|
+
const selected = new Set(context.selectedValues)
|
|
216
|
+
let cursorIndex = 0
|
|
217
|
+
let renderedLines = 0
|
|
218
|
+
let statusMessage = ''
|
|
219
|
+
let closed = false
|
|
220
|
+
const wasRaw = stdin.isRaw === true
|
|
221
|
+
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
const finish = (value) => {
|
|
224
|
+
if (closed) {
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
closed = true
|
|
229
|
+
stdin.off('data', onData)
|
|
230
|
+
stdin.setRawMode(wasRaw)
|
|
231
|
+
stdin.pause()
|
|
232
|
+
resolve(value)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const render = () => {
|
|
236
|
+
renderedLines = renderCheckboxChoices(message, choices, context, cursorIndex, selected, statusMessage, renderedLines)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const moveCursor = (offset) => {
|
|
240
|
+
statusMessage = ''
|
|
241
|
+
cursorIndex = (cursorIndex + offset + choices.length) % choices.length
|
|
242
|
+
render()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const toggleCurrent = () => {
|
|
246
|
+
statusMessage = ''
|
|
247
|
+
const currentValue = choices[cursorIndex].value
|
|
248
|
+
if (selected.has(currentValue)) {
|
|
249
|
+
selected.delete(currentValue)
|
|
250
|
+
render()
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (selected.size >= context.maxSelections) {
|
|
255
|
+
statusMessage = `最多只能选择 ${context.maxSelections} 项。`
|
|
256
|
+
render()
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
selected.add(currentValue)
|
|
261
|
+
render()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const complete = () => {
|
|
265
|
+
if (selected.size < context.minSelections) {
|
|
266
|
+
statusMessage = `至少需要选择 ${context.minSelections} 项。`
|
|
267
|
+
render()
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
finish(collectSelectedValues(choices, selected, context.maxSelections))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function onData(chunk) {
|
|
274
|
+
const input = chunk.toString('utf8')
|
|
275
|
+
|
|
276
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
277
|
+
if (closed) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const sequence = input.slice(index, index + 3)
|
|
282
|
+
if (sequence === '\u001b[A') {
|
|
283
|
+
moveCursor(-1)
|
|
284
|
+
index += 2
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (sequence === '\u001b[B') {
|
|
289
|
+
moveCursor(1)
|
|
290
|
+
index += 2
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const key = input[index]
|
|
295
|
+
if (key === '\u0003' || key === '\u001b' || key.toLowerCase() === 'q') {
|
|
296
|
+
finish(null)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (key === '\r' || key === '\n') {
|
|
301
|
+
complete()
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (key === ' ') {
|
|
306
|
+
toggleCurrent()
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (key.toLowerCase() === 'k') {
|
|
311
|
+
moveCursor(-1)
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (key.toLowerCase() === 'j') {
|
|
316
|
+
moveCursor(1)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
stdin.setRawMode(true)
|
|
322
|
+
stdin.resume()
|
|
323
|
+
stdin.on('data', onData)
|
|
324
|
+
render()
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 在给定选项中进行多选;非交互环境返回默认值集合。
|
|
330
|
+
* 输入为提示文案、选项列表与多选配置,输出为选中的 value 数组或 null。
|
|
331
|
+
*/
|
|
332
|
+
export async function chooseMany(message, choices, options = {}) {
|
|
333
|
+
if (!Array.isArray(choices) || choices.length === 0) {
|
|
334
|
+
throw new Error('chooseMany 需要至少一个可选项')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const parsedMin = Number(options.minSelections ?? 0)
|
|
338
|
+
const parsedMax = Number(options.maxSelections ?? choices.length)
|
|
339
|
+
const minSelections = Number.isFinite(parsedMin) ? Math.max(0, Math.floor(parsedMin)) : 0
|
|
340
|
+
const maxCap = Number.isFinite(parsedMax) ? Math.max(0, Math.floor(parsedMax)) : choices.length
|
|
341
|
+
const maxSelections = Math.max(minSelections, Math.min(choices.length, maxCap))
|
|
342
|
+
const doneLabel = options.doneLabel || '完成选择'
|
|
343
|
+
const cancelLabel = options.cancelLabel || '取消'
|
|
344
|
+
const defaultValues = Array.isArray(options.defaultValues) ? options.defaultValues : []
|
|
345
|
+
const defaultSet = new Set(defaultValues)
|
|
346
|
+
const selectedValues = choices
|
|
347
|
+
.map((choice) => choice.value)
|
|
348
|
+
.filter((value) => defaultSet.has(value))
|
|
349
|
+
.slice(0, maxSelections)
|
|
350
|
+
|
|
351
|
+
if (!isInteractive()) {
|
|
352
|
+
return selectedValues
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const context = {
|
|
356
|
+
minSelections,
|
|
357
|
+
maxSelections,
|
|
358
|
+
doneLabel,
|
|
359
|
+
cancelLabel,
|
|
360
|
+
selectedValues
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (typeof stdin.setRawMode !== 'function') {
|
|
364
|
+
return chooseManyByNumberInput(message, choices, context)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return chooseManyByCheckbox(message, choices, context)
|
|
368
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
const SPINNER_FRAMES = ['-', '\\', '|', '/']
|
|
2
2
|
|
|
3
|
+
// 判断当前进程是否适合渲染动态等待效果,输入为空,输出布尔值。
|
|
3
4
|
function canRenderSpinner() {
|
|
4
5
|
return Boolean(process.stderr.isTTY) && process.env.ICODE_NO_SPINNER !== '1'
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
// 清理当前终端行,输入为空,输出为空,仅在 TTY 中生效。
|
|
7
9
|
function clearLine() {
|
|
8
10
|
if (!canRenderSpinner()) {
|
|
9
11
|
return
|
|
@@ -12,6 +14,7 @@ function clearLine() {
|
|
|
12
14
|
process.stderr.write('\r\x1b[2K')
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
// 执行异步任务时展示等待动画,输入展示文案和任务函数,输出任务结果。
|
|
15
18
|
export async function withSpinner(text, task) {
|
|
16
19
|
if (!canRenderSpinner()) {
|
|
17
20
|
return task()
|
|
@@ -21,7 +24,8 @@ export async function withSpinner(text, task) {
|
|
|
21
24
|
const render = () => {
|
|
22
25
|
const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]
|
|
23
26
|
frameIndex += 1
|
|
24
|
-
|
|
27
|
+
clearLine()
|
|
28
|
+
process.stderr.write(`[icode] ${frame} ${text}`)
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
render()
|
package/src/core/tools/logger.js
CHANGED
|
@@ -25,6 +25,11 @@ function print(stream, level, message) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const logger = {
|
|
28
|
+
// 按指定颜色渲染短文本,输入颜色名和文本,输出可直接拼进日志的 ANSI 字符串。
|
|
29
|
+
color(color, message) {
|
|
30
|
+
return colorize(color, String(message ?? ''))
|
|
31
|
+
},
|
|
32
|
+
|
|
28
33
|
setVerbose(enabled) {
|
|
29
34
|
verboseEnabled = Boolean(enabled)
|
|
30
35
|
},
|