@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.
- package/README.md +346 -0
- package/bin/icode.js +6 -0
- package/package.json +34 -0
- package/src/cli.js +131 -0
- package/src/commands/ai.js +287 -0
- package/src/commands/checkout.js +59 -0
- package/src/commands/clean.js +65 -0
- package/src/commands/codereview.js +52 -0
- package/src/commands/config.js +513 -0
- package/src/commands/explain.js +80 -0
- package/src/commands/help.js +49 -0
- package/src/commands/info.js +57 -0
- package/src/commands/migrate.js +86 -0
- package/src/commands/push.js +125 -0
- package/src/commands/sync.js +74 -0
- package/src/commands/tag.js +53 -0
- package/src/commands/undo.js +66 -0
- package/src/core/ai-client.js +1125 -0
- package/src/core/ai-commit-summary.js +18 -0
- package/src/core/ai-config.js +342 -0
- package/src/core/ai-diff-range.js +117 -0
- package/src/core/args.js +47 -0
- package/src/core/commit-conventions.js +169 -0
- package/src/core/config-store.js +194 -0
- package/src/core/errors.js +25 -0
- package/src/core/git-context.js +105 -0
- package/src/core/git-service.js +428 -0
- package/src/core/hook-diagnostics.js +23 -0
- package/src/core/loading.js +36 -0
- package/src/core/logger.js +55 -0
- package/src/core/prompts.js +152 -0
- package/src/core/shell.js +77 -0
- package/src/workflows/ai-codereview-workflow.js +126 -0
- package/src/workflows/ai-commit-workflow.js +128 -0
- package/src/workflows/ai-conflict-workflow.js +102 -0
- package/src/workflows/ai-explain-workflow.js +116 -0
- package/src/workflows/ai-risk-review-workflow.js +49 -0
- package/src/workflows/checkout-workflow.js +85 -0
- package/src/workflows/clean-workflow.js +131 -0
- package/src/workflows/info-workflow.js +30 -0
- package/src/workflows/migrate-workflow.js +449 -0
- package/src/workflows/push-workflow.js +276 -0
- package/src/workflows/rollback-workflow.js +84 -0
- package/src/workflows/sync-workflow.js +141 -0
- package/src/workflows/tag-workflow.js +64 -0
- 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
|
+
}
|