@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/README.md +161 -0
- package/dist/index.js +9 -9
- package/dist/index.js.map +19 -38
- package/package.json +7 -6
- package/src/app.ts +773 -0
- package/src/console.ts +2 -109
- package/src/helpers.ts +1 -1
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
|
+
}
|