@stacksjs/cli 0.63.1 → 0.64.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/src/app.ts ADDED
@@ -0,0 +1,773 @@
1
+ // slightly modified version of @clack/prompts
2
+ // many thanks to bombshell-dev for the original work
3
+ import {
4
+ ConfirmPrompt,
5
+ GroupMultiSelectPrompt,
6
+ MultiSelectPrompt,
7
+ PasswordPrompt,
8
+ SelectKeyPrompt,
9
+ SelectPrompt,
10
+ type State,
11
+ TextPrompt,
12
+ block,
13
+ isCancel,
14
+ } from '@clack/core'
15
+ import isUnicodeSupported from 'is-unicode-supported'
16
+ import color from 'picocolors'
17
+ import { cursor, erase } from 'sisteransi'
18
+
19
+ export { isCancel } from '@clack/core'
20
+
21
+ const unicode = isUnicodeSupported()
22
+ const s = (c: string, fallback: string) => (unicode ? c : fallback)
23
+ const S_STEP_ACTIVE = s('◆', '*')
24
+ const S_STEP_CANCEL = s('■', 'x')
25
+ const S_STEP_ERROR = s('▲', 'x')
26
+ const S_STEP_SUBMIT = s('◇', 'o')
27
+
28
+ const S_BAR_START = s('┌', 'T')
29
+ const S_BAR = s('│', '|')
30
+ const S_BAR_END = s('└', '—')
31
+
32
+ const S_RADIO_ACTIVE = s('●', '>')
33
+ const S_RADIO_INACTIVE = s('○', ' ')
34
+ const S_CHECKBOX_ACTIVE = s('◻', '[•]')
35
+ const S_CHECKBOX_SELECTED = s('◼', '[+]')
36
+ const S_CHECKBOX_INACTIVE = s('◻', '[ ]')
37
+ const S_PASSWORD_MASK = s('▪', '•')
38
+
39
+ const S_BAR_H = s('─', '-')
40
+ const S_CORNER_TOP_RIGHT = s('╮', '+')
41
+ const S_CONNECT_LEFT = s('├', '+')
42
+ const S_CORNER_BOTTOM_RIGHT = s('╯', '+')
43
+
44
+ const symbol = (state: State) => {
45
+ switch (state) {
46
+ case 'initial':
47
+ case 'active':
48
+ return color.cyan(S_STEP_ACTIVE)
49
+ case 'cancel':
50
+ return color.red(S_STEP_CANCEL)
51
+ case 'error':
52
+ return color.yellow(S_STEP_ERROR)
53
+ case 'submit':
54
+ return color.green(S_STEP_SUBMIT)
55
+ }
56
+ }
57
+
58
+ interface LimitOptionsParams<TOption> {
59
+ options: TOption[]
60
+ maxItems: number | undefined
61
+ cursor: number
62
+ style: (option: TOption, active: boolean) => string
63
+ }
64
+
65
+ const limitOptions = <TOption>(params: LimitOptionsParams<TOption>): string[] => {
66
+ const { cursor, options, style } = params
67
+
68
+ const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY
69
+ const outputMaxItems = Math.max(process.stdout.rows - 4, 0)
70
+ // We clamp to minimum 5 because anything less doesn't make sense UX wise
71
+ const maxItems = Math.min(outputMaxItems, Math.max(paramMaxItems, 5))
72
+ let slidingWindowLocation = 0
73
+
74
+ if (cursor >= slidingWindowLocation + maxItems - 3) {
75
+ slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0)
76
+ } else if (cursor < slidingWindowLocation + 2) {
77
+ slidingWindowLocation = Math.max(cursor - 2, 0)
78
+ }
79
+
80
+ const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0
81
+ const shouldRenderBottomEllipsis = maxItems < options.length && slidingWindowLocation + maxItems < options.length
82
+
83
+ return options.slice(slidingWindowLocation, slidingWindowLocation + maxItems).map((option, i, arr) => {
84
+ const isTopLimit = i === 0 && shouldRenderTopEllipsis
85
+ const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis
86
+ return isTopLimit || isBottomLimit ? color.dim('...') : style(option, i + slidingWindowLocation === cursor)
87
+ })
88
+ }
89
+
90
+ export interface TextOptions {
91
+ message: string
92
+ placeholder?: string
93
+ defaultValue?: string
94
+ initialValue?: string
95
+ // biome-ignore lint/suspicious/noConfusingVoidType: originally shipped this
96
+ validate?: (value: string) => string | void
97
+ }
98
+ export const text = (opts: TextOptions) => {
99
+ return new TextPrompt({
100
+ validate: opts.validate,
101
+ placeholder: opts.placeholder,
102
+ defaultValue: opts.defaultValue,
103
+ initialValue: opts.initialValue,
104
+ render() {
105
+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`
106
+ const placeholder = opts.placeholder
107
+ ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1))
108
+ : color.inverse(color.hidden('_'))
109
+ const value = !this.value ? placeholder : this.valueWithCursor
110
+
111
+ switch (this.state) {
112
+ case 'error':
113
+ return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow(
114
+ S_BAR_END,
115
+ )} ${color.yellow(this.error)}\n`
116
+ case 'submit':
117
+ return `${title}${color.gray(S_BAR)} ${color.dim(this.value || opts.placeholder)}`
118
+ case 'cancel':
119
+ return `${title}${color.gray(S_BAR)} ${color.strikethrough(
120
+ color.dim(this.value ?? ''),
121
+ )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`
122
+ default:
123
+ return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`
124
+ }
125
+ },
126
+ }).prompt() as Promise<string | symbol>
127
+ }
128
+
129
+ export interface PasswordOptions {
130
+ message: string
131
+ mask?: string
132
+ // biome-ignore lint/suspicious/noConfusingVoidType: originally shipped this
133
+ validate?: (value: string) => string | void
134
+ }
135
+ export const password = (opts: PasswordOptions) => {
136
+ return new PasswordPrompt({
137
+ validate: opts.validate,
138
+ mask: opts.mask ?? S_PASSWORD_MASK,
139
+ render() {
140
+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`
141
+ const value = this.valueWithCursor
142
+ const masked = this.masked
143
+
144
+ switch (this.state) {
145
+ case 'error':
146
+ return `${title.trim()}\n${color.yellow(S_BAR)} ${masked}\n${color.yellow(
147
+ S_BAR_END,
148
+ )} ${color.yellow(this.error)}\n`
149
+ case 'submit':
150
+ return `${title}${color.gray(S_BAR)} ${color.dim(masked)}`
151
+ case 'cancel':
152
+ return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${
153
+ masked ? `\n${color.gray(S_BAR)}` : ''
154
+ }`
155
+ default:
156
+ return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`
157
+ }
158
+ },
159
+ }).prompt() as Promise<string | symbol>
160
+ }
161
+
162
+ export interface ConfirmOptions {
163
+ message: string
164
+ active?: string
165
+ inactive?: string
166
+ initialValue?: boolean
167
+ }
168
+ export const confirm = (opts: ConfirmOptions) => {
169
+ const active = opts.active ?? 'Yes'
170
+ const inactive = opts.inactive ?? 'No'
171
+ return new ConfirmPrompt({
172
+ active,
173
+ inactive,
174
+ initialValue: opts.initialValue ?? true,
175
+ render() {
176
+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`
177
+ const value = this.value ? active : inactive
178
+
179
+ switch (this.state) {
180
+ case 'submit':
181
+ return `${title}${color.gray(S_BAR)} ${color.dim(value)}`
182
+ case 'cancel':
183
+ return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(value))}\n${color.gray(S_BAR)}`
184
+ default: {
185
+ return `${title}${color.cyan(S_BAR)} ${
186
+ this.value
187
+ ? `${color.green(S_RADIO_ACTIVE)} ${active}`
188
+ : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}`
189
+ } ${color.dim('/')} ${
190
+ !this.value
191
+ ? `${color.green(S_RADIO_ACTIVE)} ${inactive}`
192
+ : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}`
193
+ }\n${color.cyan(S_BAR_END)}\n`
194
+ }
195
+ }
196
+ },
197
+ }).prompt() as Promise<boolean | symbol>
198
+ }
199
+
200
+ type Primitive = Readonly<string | boolean | number>
201
+
202
+ type Option<Value> = Value extends Primitive
203
+ ? { value: Value; label?: string; hint?: string }
204
+ : { value: Value; label: string; hint?: string }
205
+
206
+ export interface SelectOptions<Value> {
207
+ message: string
208
+ options: Option<Value>[]
209
+ initialValue?: Value
210
+ maxItems?: number
211
+ }
212
+
213
+ export const select = <Value>(opts: SelectOptions<Value>) => {
214
+ const opt = (option: Option<Value>, state: 'inactive' | 'active' | 'selected' | 'cancelled') => {
215
+ const label = option.label ?? String(option.value)
216
+ switch (state) {
217
+ case 'selected':
218
+ return `${color.dim(label)}`
219
+ case 'active':
220
+ return `${color.green(S_RADIO_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`
221
+ case 'cancelled':
222
+ return `${color.strikethrough(color.dim(label))}`
223
+ default:
224
+ return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`
225
+ }
226
+ }
227
+
228
+ return new SelectPrompt({
229
+ options: opts.options,
230
+ initialValue: opts.initialValue,
231
+ render() {
232
+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`
233
+
234
+ switch (this.state) {
235
+ case 'submit':
236
+ // biome-ignore lint/style/noNonNullAssertion: will be set
237
+ return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor]!, 'selected')}`
238
+ case 'cancel':
239
+ // biome-ignore lint/style/noNonNullAssertion: will be set
240
+ return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor]!, 'cancelled')}\n${color.gray(S_BAR)}`
241
+ default: {
242
+ return `${title}${color.cyan(S_BAR)} ${limitOptions({
243
+ cursor: this.cursor,
244
+ options: this.options,
245
+ maxItems: opts.maxItems,
246
+ style: (item, active) => opt(item, active ? 'active' : 'inactive'),
247
+ }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`
248
+ }
249
+ }
250
+ },
251
+ }).prompt() as Promise<Value | symbol>
252
+ }
253
+
254
+ export const selectKey = <Value extends string>(opts: SelectOptions<Value>) => {
255
+ const opt = (option: Option<Value>, state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive') => {
256
+ const label = option.label ?? String(option.value)
257
+
258
+ if (state === 'selected') {
259
+ return `${color.dim(label)}`
260
+ }
261
+
262
+ if (state === 'cancelled') {
263
+ return `${color.strikethrough(color.dim(label))}`
264
+ }
265
+
266
+ if (state === 'active') {
267
+ return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${
268
+ option.hint ? color.dim(`(${option.hint})`) : ''
269
+ }`
270
+ }
271
+
272
+ return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${
273
+ option.hint ? color.dim(`(${option.hint})`) : ''
274
+ }`
275
+ }
276
+
277
+ return new SelectKeyPrompt({
278
+ options: opts.options,
279
+ initialValue: opts.initialValue,
280
+ render() {
281
+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`
282
+
283
+ switch (this.state) {
284
+ case 'submit':
285
+ return `${title}${color.gray(S_BAR)} ${opt(
286
+ // biome-ignore lint/style/noNonNullAssertion: will be set
287
+ this.options.find((opt) => opt.value === this.value)!,
288
+ 'selected',
289
+ )}`
290
+ case 'cancel':
291
+ // biome-ignore lint/style/noNonNullAssertion: will be set
292
+ return `${title}${color.gray(S_BAR)} ${opt(this.options[0]!, 'cancelled')}\n${color.gray(S_BAR)}`
293
+ default: {
294
+ return `${title}${color.cyan(S_BAR)} ${this.options
295
+ .map((option, i) => opt(option, i === this.cursor ? 'active' : 'inactive'))
296
+ .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`
297
+ }
298
+ }
299
+ },
300
+ }).prompt() as Promise<Value | symbol>
301
+ }
302
+
303
+ export interface MultiSelectOptions<Value> {
304
+ message: string
305
+ options: Option<Value>[]
306
+ initialValues?: Value[]
307
+ maxItems?: number
308
+ required?: boolean
309
+ cursorAt?: Value
310
+ }
311
+ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
312
+ const opt = (
313
+ option: Option<Value>,
314
+ state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'submitted' | 'cancelled',
315
+ ) => {
316
+ const label = option.label ?? String(option.value)
317
+
318
+ if (state === 'active') {
319
+ return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`
320
+ }
321
+
322
+ if (state === 'selected') {
323
+ return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`
324
+ }
325
+
326
+ if (state === 'cancelled') {
327
+ return `${color.strikethrough(color.dim(label))}`
328
+ }
329
+
330
+ if (state === 'active-selected') {
331
+ return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : ''}`
332
+ }
333
+
334
+ if (state === 'submitted') {
335
+ return `${color.dim(label)}`
336
+ }
337
+
338
+ return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`
339
+ }
340
+
341
+ return new MultiSelectPrompt({
342
+ options: opts.options,
343
+ initialValues: opts.initialValues,
344
+ required: opts.required ?? true,
345
+ cursorAt: opts.cursorAt,
346
+ validate(selected: Value[]) {
347
+ if (this.required && selected.length === 0)
348
+ return `Please select at least one option.\n${color.reset(
349
+ color.dim(
350
+ `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray(
351
+ color.bgWhite(color.inverse(' enter ')),
352
+ )} to submit`,
353
+ ),
354
+ )}`
355
+ },
356
+ render() {
357
+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`
358
+
359
+ const styleOption = (option: Option<Value>, active: boolean) => {
360
+ const selected = this.value.includes(option.value)
361
+ if (active && selected) {
362
+ return opt(option, 'active-selected')
363
+ }
364
+ if (selected) {
365
+ return opt(option, 'selected')
366
+ }
367
+ return opt(option, active ? 'active' : 'inactive')
368
+ }
369
+
370
+ switch (this.state) {
371
+ case 'submit': {
372
+ return `${title}${color.gray(S_BAR)} ${
373
+ this.options
374
+ .filter(({ value }) => this.value.includes(value))
375
+ .map((option) => opt(option, 'submitted'))
376
+ .join(color.dim(', ')) || color.dim('none')
377
+ }`
378
+ }
379
+ case 'cancel': {
380
+ const label = this.options
381
+ .filter(({ value }) => this.value.includes(value))
382
+ .map((option) => opt(option, 'cancelled'))
383
+ .join(color.dim(', '))
384
+ return `${title}${color.gray(S_BAR)} ${label.trim() ? `${label}\n${color.gray(S_BAR)}` : ''}`
385
+ }
386
+ case 'error': {
387
+ const footer = this.error
388
+ .split('\n')
389
+ .map((ln, i) => (i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`))
390
+ .join('\n')
391
+ return `
392
+ ${title}
393
+ ${color.yellow(S_BAR)}
394
+ ${limitOptions({
395
+ options: this.options,
396
+ cursor: this.cursor,
397
+ maxItems: opts.maxItems,
398
+ style: styleOption,
399
+ }).join(`\n${color.yellow(S_BAR)} `)}
400
+ \n${footer}\n`
401
+ }
402
+ default: {
403
+ return `${title}${color.cyan(S_BAR)} ${limitOptions({
404
+ options: this.options,
405
+ cursor: this.cursor,
406
+ maxItems: opts.maxItems,
407
+ style: styleOption,
408
+ }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`
409
+ }
410
+ }
411
+ },
412
+ }).prompt() as Promise<Value[] | symbol>
413
+ }
414
+
415
+ export interface GroupMultiSelectOptions<Value> {
416
+ message: string
417
+ options: Record<string, Option<Value>[]>
418
+ initialValues?: Value[]
419
+ required?: boolean
420
+ cursorAt?: Value
421
+ }
422
+ export const groupMultiselect = <Value>(opts: GroupMultiSelectOptions<Value>) => {
423
+ const opt = (
424
+ option: Option<Value>,
425
+ state:
426
+ | 'inactive'
427
+ | 'active'
428
+ | 'selected'
429
+ | 'active-selected'
430
+ | 'group-active'
431
+ | 'group-active-selected'
432
+ | 'submitted'
433
+ | 'cancelled',
434
+ options: Option<Value>[] = [],
435
+ ) => {
436
+ const label = option.label ?? String(option.value)
437
+ const isItem = typeof (option as any).group === 'string'
438
+ const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true })
439
+ const isLast = isItem && (next as any).group === true
440
+ const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ''
441
+
442
+ if (state === 'active') {
443
+ return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${
444
+ option.hint ? color.dim(`(${option.hint})`) : ''
445
+ }`
446
+ }
447
+
448
+ if (state === 'group-active') {
449
+ return `${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`
450
+ }
451
+
452
+ if (state === 'group-active-selected') {
453
+ return `${prefix}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`
454
+ }
455
+
456
+ if (state === 'selected') {
457
+ return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`
458
+ }
459
+
460
+ if (state === 'cancelled') {
461
+ return `${color.strikethrough(color.dim(label))}`
462
+ }
463
+
464
+ if (state === 'active-selected') {
465
+ return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${
466
+ option.hint ? color.dim(`(${option.hint})`) : ''
467
+ }`
468
+ }
469
+
470
+ if (state === 'submitted') {
471
+ return `${color.dim(label)}`
472
+ }
473
+
474
+ return `${color.dim(prefix)}${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`
475
+ }
476
+
477
+ return new GroupMultiSelectPrompt({
478
+ options: opts.options,
479
+ initialValues: opts.initialValues,
480
+ required: opts.required ?? true,
481
+ cursorAt: opts.cursorAt,
482
+ validate(selected: Value[]) {
483
+ if (this.required && selected.length === 0)
484
+ return `Please select at least one option.\n${color.reset(
485
+ color.dim(
486
+ `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray(
487
+ color.bgWhite(color.inverse(' enter ')),
488
+ )} to submit`,
489
+ ),
490
+ )}`
491
+ },
492
+ render() {
493
+ const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`
494
+
495
+ switch (this.state) {
496
+ case 'submit': {
497
+ return `${title}${color.gray(S_BAR)} ${this.options
498
+ .filter(({ value }) => this.value.includes(value))
499
+ .map((option) => opt(option, 'submitted'))
500
+ .join(color.dim(', '))}`
501
+ }
502
+ case 'cancel': {
503
+ const label = this.options
504
+ .filter(({ value }) => this.value.includes(value))
505
+ .map((option) => opt(option, 'cancelled'))
506
+ .join(color.dim(', '))
507
+ return `${title}${color.gray(S_BAR)} ${label.trim() ? `${label}\n${color.gray(S_BAR)}` : ''}`
508
+ }
509
+ case 'error': {
510
+ const footer = this.error
511
+ .split('\n')
512
+ .map((ln, i) => (i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`))
513
+ .join('\n')
514
+ return `${title}${color.yellow(S_BAR)} ${this.options
515
+ .map((option, i, options) => {
516
+ const selected =
517
+ this.value.includes(option.value) || (option.group === true && this.isGroupSelected(`${option.value}`))
518
+ const active = i === this.cursor
519
+ const groupActive =
520
+ // biome-ignore lint/style/noNonNullAssertion: will be set
521
+ !active && typeof option.group === 'string' && this.options[this.cursor]!.value === option.group
522
+ if (groupActive) {
523
+ return opt(option, selected ? 'group-active-selected' : 'group-active', options)
524
+ }
525
+ if (active && selected) {
526
+ return opt(option, 'active-selected', options)
527
+ }
528
+ if (selected) {
529
+ return opt(option, 'selected', options)
530
+ }
531
+ return opt(option, active ? 'active' : 'inactive', options)
532
+ })
533
+ .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`
534
+ }
535
+ default: {
536
+ return `${title}${color.cyan(S_BAR)} ${this.options
537
+ .map((option, i, options) => {
538
+ const selected =
539
+ this.value.includes(option.value) || (option.group === true && this.isGroupSelected(`${option.value}`))
540
+ const active = i === this.cursor
541
+ const groupActive =
542
+ // biome-ignore lint/style/noNonNullAssertion: will be set
543
+ !active && typeof option.group === 'string' && this.options[this.cursor]!.value === option.group
544
+ if (groupActive) {
545
+ return opt(option, selected ? 'group-active-selected' : 'group-active', options)
546
+ }
547
+
548
+ if (active && selected) {
549
+ return opt(option, 'active-selected', options)
550
+ }
551
+
552
+ if (selected) {
553
+ return opt(option, 'selected', options)
554
+ }
555
+
556
+ return opt(option, active ? 'active' : 'inactive', options)
557
+ })
558
+ .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`
559
+ }
560
+ }
561
+ },
562
+ }).prompt() as Promise<Value[] | symbol>
563
+ }
564
+
565
+ const strip = (str: string) => str.replace(ansiRegex(), '')
566
+ export const note = (message = '', title = '') => {
567
+ const lines = `\n${message}\n`.split('\n')
568
+ const titleLen = strip(title).length
569
+ const len =
570
+ Math.max(
571
+ lines.reduce((sum, ln) => {
572
+ ln = strip(ln)
573
+ return ln.length > sum ? ln.length : sum
574
+ }, 0),
575
+ titleLen,
576
+ ) + 2
577
+ const msg = lines
578
+ .map((ln) => `${color.gray(S_BAR)} ${color.dim(ln)}${' '.repeat(len - strip(ln).length)}${color.gray(S_BAR)}`)
579
+ .join('\n')
580
+ process.stdout.write(
581
+ `${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray(
582
+ S_BAR_H.repeat(Math.max(len - titleLen - 1, 1)) + S_CORNER_TOP_RIGHT,
583
+ )}\n${msg}\n${color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n`,
584
+ )
585
+ }
586
+
587
+ export const cancel = (message = '') => {
588
+ process.stdout.write(`${color.gray(S_BAR_END)} ${color.red(message)}\n\n`)
589
+ }
590
+
591
+ export const intro = (title = '') => {
592
+ process.stdout.write(`${color.gray(S_BAR_START)} ${title}\n`)
593
+ }
594
+
595
+ export const outro = (message = '') => {
596
+ process.stdout.write(`${color.gray(S_BAR)}\n${color.gray(S_BAR_END)} ${message}\n\n`)
597
+ }
598
+
599
+ export const spinner = () => {
600
+ const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0']
601
+ const delay = unicode ? 80 : 120
602
+
603
+ let unblock: () => void
604
+ let loop: NodeJS.Timeout
605
+ let isSpinnerActive = false
606
+ let _message = ''
607
+
608
+ const handleExit = (code: number) => {
609
+ const msg = code > 1 ? 'Something went wrong' : 'Canceled'
610
+ if (isSpinnerActive) stop(msg, code)
611
+ }
612
+
613
+ const errorEventHandler = () => handleExit(2)
614
+ const signalEventHandler = () => handleExit(1)
615
+
616
+ const registerHooks = () => {
617
+ // Reference: https://nodejs.org/api/process.html#event-uncaughtexception
618
+ process.on('uncaughtExceptionMonitor', errorEventHandler)
619
+ // Reference: https://nodejs.org/api/process.html#event-unhandledrejection
620
+ process.on('unhandledRejection', errorEventHandler)
621
+ // Reference Signal Events: https://nodejs.org/api/process.html#signal-events
622
+ process.on('SIGINT', signalEventHandler)
623
+ process.on('SIGTERM', signalEventHandler)
624
+ process.on('exit', handleExit)
625
+ }
626
+
627
+ const clearHooks = () => {
628
+ process.removeListener('uncaughtExceptionMonitor', errorEventHandler)
629
+ process.removeListener('unhandledRejection', errorEventHandler)
630
+ process.removeListener('SIGINT', signalEventHandler)
631
+ process.removeListener('SIGTERM', signalEventHandler)
632
+ process.removeListener('exit', handleExit)
633
+ }
634
+
635
+ const start = (msg = ''): void => {
636
+ let loop: ReturnType<typeof setInterval>
637
+ isSpinnerActive = true
638
+ unblock = block()
639
+ _message = msg.replace(/\.+$/, '')
640
+ process.stdout.write(`${color.gray(S_BAR)}\n`)
641
+ let frameIndex = 0
642
+ let dotsTimer = 0
643
+ registerHooks()
644
+ loop = setInterval(() => {
645
+ const frame = color.magenta(frames[frameIndex])
646
+ const loadingDots = '.'.repeat(Math.floor(dotsTimer)).slice(0, 3)
647
+ process.stdout.write(cursor.move(-999, 0))
648
+ process.stdout.write(erase.down(1))
649
+ process.stdout.write(`${frame} ${_message}${loadingDots}`)
650
+ frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0
651
+ dotsTimer = dotsTimer < frames.length ? dotsTimer + 0.125 : 0
652
+ }, delay)
653
+ }
654
+
655
+ const stop = (msg = '', code = 0): void => {
656
+ _message = msg ?? _message
657
+ isSpinnerActive = false
658
+ clearInterval(loop as NodeJS.Timeout)
659
+ const step =
660
+ code === 0 ? color.green(S_STEP_SUBMIT) : code === 1 ? color.red(S_STEP_CANCEL) : color.red(S_STEP_ERROR)
661
+ process.stdout.write(cursor.move(-999, 0))
662
+ process.stdout.write(erase.down(1))
663
+ process.stdout.write(`${step} ${_message}\n`)
664
+ clearHooks()
665
+ unblock()
666
+ }
667
+
668
+ const message = (msg = ''): void => {
669
+ _message = msg ?? _message
670
+ }
671
+
672
+ return {
673
+ start,
674
+ stop,
675
+ message,
676
+ }
677
+ }
678
+
679
+ // Adapted from https://github.com/chalk/ansi-regex
680
+ // @see LICENSE
681
+ function ansiRegex() {
682
+ const pattern = [
683
+ '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
684
+ '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))',
685
+ ].join('|')
686
+
687
+ return new RegExp(pattern, 'g')
688
+ }
689
+
690
+ export type PromptGroupAwaitedReturn<T> = {
691
+ [P in keyof T]: Exclude<Awaited<T[P]>, symbol>
692
+ }
693
+
694
+ export interface PromptGroupOptions<T> {
695
+ /**
696
+ * Control how the group can be canceled
697
+ * if one of the prompts is canceled.
698
+ */
699
+ onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void
700
+ }
701
+
702
+ type Prettify<T> = {
703
+ [P in keyof T]: T[P]
704
+ } & {}
705
+
706
+ export type PromptGroup<T> = {
707
+ [P in keyof T]: (opts: {
708
+ results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>
709
+ // biome-ignore lint/suspicious/noConfusingVoidType: originally shipped this
710
+ }) => void | Promise<T[P] | void>
711
+ }
712
+
713
+ /**
714
+ * Define a group of prompts to be displayed
715
+ * and return a results of objects within the group
716
+ */
717
+ export const group = async <T>(
718
+ prompts: PromptGroup<T>,
719
+ opts?: PromptGroupOptions<T>,
720
+ ): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
721
+ const results = {} as any
722
+ const promptNames = Object.keys(prompts)
723
+
724
+ for (const name of promptNames) {
725
+ const prompt = prompts[name as keyof T]
726
+ const result = await prompt({ results })?.catch((e) => {
727
+ throw e
728
+ })
729
+
730
+ // Pass the results to the onCancel function
731
+ // so the user can decide what to do with the results
732
+ // TODO: Switch to callback within core to avoid isCancel Fn
733
+ if (typeof opts?.onCancel === 'function' && isCancel(result)) {
734
+ results[name] = 'canceled'
735
+ opts.onCancel({ results })
736
+ continue
737
+ }
738
+
739
+ results[name] = result
740
+ }
741
+
742
+ return results
743
+ }
744
+
745
+ export type Task = {
746
+ /**
747
+ * Task title
748
+ */
749
+ title: string
750
+ /**
751
+ * Task function
752
+ */
753
+ task: (message: (string: string) => void) => string | Promise<string> | void | Promise<void>
754
+
755
+ /**
756
+ * If enabled === false the task will be skipped
757
+ */
758
+ enabled?: boolean
759
+ }
760
+
761
+ /**
762
+ * Define a group of tasks to be executed
763
+ */
764
+ export const tasks = async (tasks: Task[]) => {
765
+ for (const task of tasks) {
766
+ if (task.enabled === false) continue
767
+
768
+ const s = spinner()
769
+ s.start(task.title)
770
+ const result = await task.task(s.message)
771
+ s.stop(result || task.title)
772
+ }
773
+ }