@kaynooo/cli-kit 0.0.0-stage → 0.8.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/LICENSE +21 -0
- package/README.md +65 -2
- package/package.json +36 -4
- package/src/args.ts +234 -0
- package/src/chart.ts +88 -0
- package/src/cli.ts +296 -0
- package/src/config.ts +59 -0
- package/src/format.ts +163 -0
- package/src/index.ts +8 -0
- package/src/process.ts +120 -0
- package/src/progress.ts +314 -0
- package/src/prompt.ts +331 -0
- package/src/shell.ts +373 -0
- package/src/style.ts +103 -0
- package/src/test.ts +42 -0
- package/src/text.ts +21 -0
package/src/progress.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { Colors } from './style'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { formatDuration } from './format'
|
|
4
|
+
import { holdSignals } from './process'
|
|
5
|
+
import { marked, paint, takesControlCodes } from './style'
|
|
6
|
+
|
|
7
|
+
export interface Progress {
|
|
8
|
+
step: string
|
|
9
|
+
done?: number | undefined
|
|
10
|
+
total?: number | undefined
|
|
11
|
+
unit?: string | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ProgressStream {
|
|
15
|
+
isTTY?: boolean
|
|
16
|
+
columns?: number | undefined
|
|
17
|
+
rows?: number | undefined
|
|
18
|
+
write: (text: string) => unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LiveRegion {
|
|
22
|
+
// Draws the lines again now, rather than at the next frame.
|
|
23
|
+
redraw: () => void
|
|
24
|
+
// Prints `text` above the region, where it stays once the region goes.
|
|
25
|
+
print: (text: string) => void
|
|
26
|
+
// Clears the region and leaves nothing behind.
|
|
27
|
+
stop: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TaskHandle {
|
|
31
|
+
update: (progress: Omit<Progress, 'step'>) => void
|
|
32
|
+
// Leaves `✓ <text> <time since the task began>` above the tasks still running, the label when no text is given.
|
|
33
|
+
done: (text?: string) => void
|
|
34
|
+
// Leaves `✗ <text>`, the label when no text is given.
|
|
35
|
+
failed: (text?: string) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TaskList {
|
|
39
|
+
add: (label: string) => TaskHandle
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProgressLine {
|
|
43
|
+
update: (progress: Progress) => void
|
|
44
|
+
// Clears the line and leaves nothing behind.
|
|
45
|
+
stop: () => void
|
|
46
|
+
// Clears the line and leaves `✓ <text> <time since the line began>` in its place.
|
|
47
|
+
done: (text: string) => void
|
|
48
|
+
// Clears the line and leaves `✗ <text>` in its place.
|
|
49
|
+
failed: (text: string) => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Env = Record<string, string | undefined>
|
|
53
|
+
|
|
54
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
55
|
+
const FRAME_MS = 80
|
|
56
|
+
const BAR_WIDTH = 20
|
|
57
|
+
// Back to the start of the line, then erases it.
|
|
58
|
+
const CLEAR_LINE = '\r\x1B[2K'
|
|
59
|
+
// Up a line, then erases it.
|
|
60
|
+
const LINE_UP = '\x1B[1A\x1B[2K'
|
|
61
|
+
const ESC = String.fromCharCode(27)
|
|
62
|
+
// A colour or cursor code, or an OSC 8 link's opening or closing, read where `lastIndex` points.
|
|
63
|
+
const ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, 'y')
|
|
64
|
+
const RESET_STYLE = `${ESC}[0m`
|
|
65
|
+
const CLOSE_LINK = `${ESC}]8;;${ESC}\\`
|
|
66
|
+
// What the console prints with, to the terminal the line is drawn on or the other.
|
|
67
|
+
const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug', 'dir', 'dirxml', 'table', 'trace', 'group', 'groupCollapsed', 'count', 'timeEnd', 'timeLog', 'assert'] as const
|
|
68
|
+
|
|
69
|
+
// A piece of the line and how it is tinted, so the line can be cut to the terminal's width on its visible text.
|
|
70
|
+
interface Part { text: string, tint: (colors: Colors) => (text: string) => string }
|
|
71
|
+
|
|
72
|
+
// Lines on `stream` redrawn in place every frame, `render` handed the spinner's next glyph, each line cut to the terminal's width and the block to its height.
|
|
73
|
+
// Drawn only on a terminal that takes control codes, so elsewhere `render` never runs and the caller prints its own outcome; whatever else is printed meanwhile clears the block first, the next frame drawing it again under it, and an interrupt clears it before the process ends.
|
|
74
|
+
export function liveRegion(render: (spinner: string) => string[], stream: ProgressStream = process.stderr, env: Env = process.env): LiveRegion {
|
|
75
|
+
if (!takesControlCodes(stream, env))
|
|
76
|
+
return { redraw: () => undefined, print: text => void stream.write(`${text}\n`), stop: () => undefined }
|
|
77
|
+
|
|
78
|
+
// Its own write, taken before the region clears itself ahead of every other.
|
|
79
|
+
const write = stream.write.bind(stream)
|
|
80
|
+
let frame = 0
|
|
81
|
+
let drawn = 0
|
|
82
|
+
const clear = () => {
|
|
83
|
+
if (drawn > 0)
|
|
84
|
+
write(`${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}`)
|
|
85
|
+
|
|
86
|
+
drawn = 0
|
|
87
|
+
}
|
|
88
|
+
const draw = () => {
|
|
89
|
+
// A terminal that does not say its size, as a pseudo-terminal may, answers 0.
|
|
90
|
+
const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1
|
|
91
|
+
const height = stream.rows === undefined || stream.rows === 0 ? 24 : stream.rows
|
|
92
|
+
const lines = render(FRAMES[frame++ % FRAMES.length]!).slice(0, Math.max(1, height - 1)).map(line => cutToWidth(line, width))
|
|
93
|
+
|
|
94
|
+
if (lines.length === 0) {
|
|
95
|
+
clear()
|
|
96
|
+
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
write(`${drawn > 0 ? `${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}` : CLEAR_LINE}${lines.join('\n')}`)
|
|
101
|
+
drawn = lines.length
|
|
102
|
+
}
|
|
103
|
+
const timer = setInterval(draw, FRAME_MS)
|
|
104
|
+
const restore = interceptOutput(clear)
|
|
105
|
+
const onExit = () => clear()
|
|
106
|
+
// Interrupted, the block goes before the shell's prompt comes back.
|
|
107
|
+
const signals = holdSignals(stop)
|
|
108
|
+
|
|
109
|
+
function stop() {
|
|
110
|
+
clearInterval(timer)
|
|
111
|
+
clear()
|
|
112
|
+
restore()
|
|
113
|
+
process.off('exit', onExit)
|
|
114
|
+
signals.release()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
timer.unref()
|
|
118
|
+
process.on('exit', onExit)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
redraw: draw,
|
|
122
|
+
print: (text) => {
|
|
123
|
+
clear()
|
|
124
|
+
write(`${text}\n`)
|
|
125
|
+
draw()
|
|
126
|
+
},
|
|
127
|
+
stop,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// One line on `stream`, redrawn in place while a task runs: a spinner, the step, a bar where its total is known, and the time since the step began.
|
|
132
|
+
// Off a terminal taking control codes, a log, CI or `NO_COLOR` gets the `done` and `failed` lines alone.
|
|
133
|
+
export function progressLine(stream: ProgressStream = process.stderr, env: Env = process.env): ProgressLine {
|
|
134
|
+
const colors = paint(stream, env)
|
|
135
|
+
// Its own write, taken before the region clears itself ahead of every other.
|
|
136
|
+
const write = stream.write.bind(stream)
|
|
137
|
+
const begun = performance.now()
|
|
138
|
+
let started = begun
|
|
139
|
+
let current: Progress | undefined
|
|
140
|
+
const region = liveRegion(spinner => current === undefined ? [] : [tinted(progressParts(current, spinner, performance.now() - started), colors)], stream, env)
|
|
141
|
+
const end = (line: string) => {
|
|
142
|
+
region.stop()
|
|
143
|
+
write(`${line}\n`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
// A new step is drawn at once, its clock started again; a count moving on waits for the next frame, so a burst of updates writes one.
|
|
148
|
+
update: (progress) => {
|
|
149
|
+
const step = current?.step
|
|
150
|
+
|
|
151
|
+
current = progress
|
|
152
|
+
|
|
153
|
+
if (progress.step !== step) {
|
|
154
|
+
started = performance.now()
|
|
155
|
+
region.redraw()
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
stop: region.stop,
|
|
159
|
+
done: text => end(marked('ok', text, stream, formatDuration(performance.now() - begun), env)),
|
|
160
|
+
failed: text => end(marked('problem', text, stream, undefined, env)),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Stacked lines for tasks running side by side, each drawn as a progress line is; a task ending leaves its ✓ or ✗ line above those still running, and the block goes once none is.
|
|
165
|
+
export function taskList(stream: ProgressStream = process.stderr, env: Env = process.env): TaskList {
|
|
166
|
+
const colors = paint(stream, env)
|
|
167
|
+
const running = new Set<{ progress: Progress, started: number }>()
|
|
168
|
+
let region: LiveRegion | undefined
|
|
169
|
+
const render = (spinner: string) => [...running].map(({ progress, started }) => tinted(progressParts(progress, spinner, performance.now() - started), colors))
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
add: (label) => {
|
|
173
|
+
const entry = { progress: { step: label } as Progress, started: performance.now() }
|
|
174
|
+
const end = (line: string) => {
|
|
175
|
+
if (!running.delete(entry))
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
region?.print(line)
|
|
179
|
+
|
|
180
|
+
if (running.size === 0) {
|
|
181
|
+
region?.stop()
|
|
182
|
+
region = undefined
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
running.add(entry)
|
|
187
|
+
region ??= liveRegion(render, stream, env)
|
|
188
|
+
region.redraw()
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
update: (progress) => {
|
|
192
|
+
entry.progress = { ...progress, step: label }
|
|
193
|
+
},
|
|
194
|
+
done: text => end(marked('ok', text ?? label, stream, formatDuration(performance.now() - entry.started), env)),
|
|
195
|
+
failed: text => end(marked('problem', text ?? label, stream, undefined, env)),
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Runs one step under a spinner on a terminal, then leaves `✓ <label> <time>`, or `✗ <label>` and throws again; off a terminal those lines alone print, as `ok:` and `error:`.
|
|
202
|
+
export async function task<T>(label: string, run: (update: (progress: Omit<Progress, 'step'>) => void) => Promise<T>, stream: ProgressStream = process.stderr, env: Env = process.env): Promise<T> {
|
|
203
|
+
const line = progressLine(stream, env)
|
|
204
|
+
|
|
205
|
+
line.update({ step: label })
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const result = await run(progress => line.update({ ...progress, step: label }))
|
|
209
|
+
|
|
210
|
+
line.done(label)
|
|
211
|
+
|
|
212
|
+
return result
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
line.failed(label)
|
|
216
|
+
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// `⠹ prerendering ████████░░░░░░░░░░░░ 8/20 routes 4.2s`; a step with no total has no bar.
|
|
222
|
+
export function progressText(progress: Progress, spinner: string, ms: number): string {
|
|
223
|
+
return progressParts(progress, spinner, ms).map(part => part.text).join('')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function progressParts({ step, done, total, unit }: Progress, spinner: string, ms: number): Part[] {
|
|
227
|
+
const parts: Part[] = [{ text: spinner, tint: colors => colors.cyan }, { text: ` ${step}`, tint: () => plain }]
|
|
228
|
+
|
|
229
|
+
if (total !== undefined && total > 0) {
|
|
230
|
+
const counted = Math.min(done ?? 0, total)
|
|
231
|
+
const filled = Math.round(counted / total * BAR_WIDTH)
|
|
232
|
+
|
|
233
|
+
parts.push(
|
|
234
|
+
{ text: ' ', tint: () => plain },
|
|
235
|
+
{ text: '█'.repeat(filled), tint: colors => colors.cyan },
|
|
236
|
+
{ text: '░'.repeat(BAR_WIDTH - filled), tint: colors => colors.dim },
|
|
237
|
+
{ text: ` ${counted}/${total}${unit === undefined ? '' : ` ${unit}`}`, tint: colors => colors.dim },
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
parts.push({ text: ` ${(ms / 1000).toFixed(1)}s`, tint: colors => colors.dim })
|
|
242
|
+
|
|
243
|
+
return parts
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function plain(text: string): string {
|
|
247
|
+
return text
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function tinted(parts: Part[], colors: Colors): string {
|
|
251
|
+
return parts.map(({ text, tint }) => tint(colors)(text)).join('')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The line cut to `width` columns of visible text, its escapes kept whole, and the styles and a link ended after a cut so none runs into the next line.
|
|
255
|
+
function cutToWidth(line: string, width: number): string {
|
|
256
|
+
if (Bun.stringWidth(line) <= width)
|
|
257
|
+
return line
|
|
258
|
+
|
|
259
|
+
let room = width
|
|
260
|
+
let kept = ''
|
|
261
|
+
let index = 0
|
|
262
|
+
let linked = false
|
|
263
|
+
|
|
264
|
+
while (index < line.length) {
|
|
265
|
+
ESCAPE_CODE.lastIndex = index
|
|
266
|
+
|
|
267
|
+
const escape = ESCAPE_CODE.exec(line)
|
|
268
|
+
|
|
269
|
+
if (escape !== null) {
|
|
270
|
+
kept += escape[0]
|
|
271
|
+
linked ||= escape[0].startsWith(`${ESC}]`)
|
|
272
|
+
index = ESCAPE_CODE.lastIndex
|
|
273
|
+
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const char = String.fromCodePoint(line.codePointAt(index)!)
|
|
278
|
+
const columns = Bun.stringWidth(char)
|
|
279
|
+
|
|
280
|
+
if (columns > room)
|
|
281
|
+
break
|
|
282
|
+
|
|
283
|
+
kept += char
|
|
284
|
+
room -= columns
|
|
285
|
+
index += char.length
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ''}`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Clears the region before anything the console or a stream writes, the caller's own code meanwhile included, and answers how to put them back.
|
|
292
|
+
function interceptOutput(clear: () => void): () => void {
|
|
293
|
+
const targets = [...CONSOLE_METHODS.map(name => [console, name] as const), [process.stdout, 'write'] as const, [process.stderr, 'write'] as const]
|
|
294
|
+
const wrapped = targets.map(([target, name]) => {
|
|
295
|
+
const original = Reflect.get(target, name) as (...args: unknown[]) => unknown
|
|
296
|
+
const wrapper = (...args: unknown[]) => {
|
|
297
|
+
clear()
|
|
298
|
+
|
|
299
|
+
return original.apply(target, args)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
Reflect.set(target, name, wrapper)
|
|
303
|
+
|
|
304
|
+
return { target, name, original, wrapper }
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// What the caller replaced meanwhile is its own, and stays.
|
|
308
|
+
return () => {
|
|
309
|
+
for (const { target, name, original, wrapper } of wrapped) {
|
|
310
|
+
if (Reflect.get(target, name) === wrapper)
|
|
311
|
+
Reflect.set(target, name, original)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type { Colors } from './style'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { fail } from './args'
|
|
4
|
+
import { truncate } from './format'
|
|
5
|
+
import { paint } from './style'
|
|
6
|
+
|
|
7
|
+
// What the terminal sends for a key: a whole escape sequence, such as an arrow's or Home's, which no prompt but the arrows reads, or one character, a lone Escape included.
|
|
8
|
+
// eslint-disable-next-line no-control-regex -- escape sequences are what it splits
|
|
9
|
+
const KEY_SEQUENCES = /\x1B\[[0-9;]*[A-Z~]|\x1BO[A-Z]|[\s\S]/giu
|
|
10
|
+
// eslint-disable-next-line no-control-regex -- control characters are what it leaves out
|
|
11
|
+
const CONTROL = /^[\x00-\x1F\x7F]/
|
|
12
|
+
const WORDS = /\s+/
|
|
13
|
+
// Choices drawn at once at most, the rest scrolled to.
|
|
14
|
+
const MAX_ROWS = 10
|
|
15
|
+
|
|
16
|
+
type Key = 'up' | 'down' | 'enter' | 'cancel' | 'erase' | { text: string }
|
|
17
|
+
type Outcome = 'enter' | 'cancel'
|
|
18
|
+
type State = 'asking' | Outcome
|
|
19
|
+
|
|
20
|
+
const KEYS: Record<string, Key> = { '\x1B[A': 'up', '\x1BOA': 'up', '\x1B[B': 'down', '\x1BOB': 'down', '\r': 'enter', '\n': 'enter', '\x03': 'cancel', '\x1B': 'cancel', '\x7F': 'erase', '\b': 'erase' }
|
|
21
|
+
|
|
22
|
+
export interface Choice<T> {
|
|
23
|
+
value: T
|
|
24
|
+
label: string
|
|
25
|
+
hint: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The ends a prompt reads keys from and draws on, the process's own by default.
|
|
29
|
+
export interface Terminal {
|
|
30
|
+
stdin: Pick<NodeJS.ReadStream, 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'> & { isTTY?: boolean }
|
|
31
|
+
stderr: Pick<NodeJS.WriteStream, 'write' | 'columns' | 'rows'> & { isTTY?: boolean }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PromptSettings {
|
|
35
|
+
// Shown under the error a prompt fails with off a terminal, such as `pass --yes`.
|
|
36
|
+
hint?: string
|
|
37
|
+
terminal?: Terminal
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The keys a chunk of terminal input holds, in order: the ones prompts read, and printable text.
|
|
41
|
+
function readKeys(input: string): Key[] {
|
|
42
|
+
return (input.match(KEY_SEQUENCES) ?? []).flatMap(sequence => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SelectSettings extends PromptSettings {
|
|
46
|
+
// The index of the choice the cursor starts on.
|
|
47
|
+
initial?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// One choice among several, typing filtering them, the arrows moving and Enter picking; undefined once Escape or Ctrl+C cancels.
|
|
51
|
+
export async function select<T>(question: string, choices: Choice<T>[], { initial = 0, ...settings }: SelectSettings = {}): Promise<T | undefined> {
|
|
52
|
+
const list = new List(choices, initial)
|
|
53
|
+
const picked = await ask(question, settings, (colors, state, rows) => state === 'asking'
|
|
54
|
+
? [asking(colors, question, list.query, 'type to filter, enter picks'), ...list.rows(colors, rows, () => '')]
|
|
55
|
+
: [answer(colors, question, state, list.current()?.label ?? '')], (key) => {
|
|
56
|
+
if (key === 'enter')
|
|
57
|
+
return list.current() === undefined ? undefined : 'enter'
|
|
58
|
+
|
|
59
|
+
if (key === 'cancel')
|
|
60
|
+
return 'cancel'
|
|
61
|
+
|
|
62
|
+
list.press(key)
|
|
63
|
+
|
|
64
|
+
return undefined
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return picked ? list.current()?.value : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ChecklistSettings<T> extends PromptSettings {
|
|
71
|
+
initial?: ReadonlySet<T>
|
|
72
|
+
// What checking or unchecking `value` leaves checked, which may change other choices along; flips `value` alone by default.
|
|
73
|
+
toggle?: (checked: ReadonlySet<T>, value: T) => ReadonlySet<T>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function flip<T>(checked: ReadonlySet<T>, value: T): ReadonlySet<T> {
|
|
77
|
+
const next = new Set(checked)
|
|
78
|
+
|
|
79
|
+
if (!next.delete(value))
|
|
80
|
+
next.add(value)
|
|
81
|
+
|
|
82
|
+
return next
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Several choices, typing filtering them, Space checking or unchecking the one under the cursor and Enter accepting what is checked; undefined once cancelled.
|
|
86
|
+
export async function checklist<T>(question: string, choices: Choice<T>[], { initial = new Set(), toggle = flip, ...settings }: ChecklistSettings<T> = {}): Promise<ReadonlySet<T> | undefined> {
|
|
87
|
+
const list = new List(choices, 0)
|
|
88
|
+
let checked = initial
|
|
89
|
+
const accepted = await ask(question, settings, (colors, state, rows) => state === 'asking'
|
|
90
|
+
? [asking(colors, question, list.query, 'type to filter, space toggles, enter accepts'), ...list.rows(colors, rows, choice => checked.has(choice.value) ? `${colors.cyan('◉')} ` : `${colors.dim('◯')} `)]
|
|
91
|
+
: [answer(colors, question, state, choices.filter(choice => checked.has(choice.value)).map(choice => choice.label).join(', ') || 'none')], (key) => {
|
|
92
|
+
if (key === 'enter' || key === 'cancel')
|
|
93
|
+
return key
|
|
94
|
+
|
|
95
|
+
const current = list.current()
|
|
96
|
+
|
|
97
|
+
if (typeof key === 'object' && key.text === ' ') {
|
|
98
|
+
if (current !== undefined)
|
|
99
|
+
checked = toggle(checked, current.value)
|
|
100
|
+
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
list.press(key)
|
|
105
|
+
|
|
106
|
+
return undefined
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return accepted ? checked : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ConfirmSettings extends PromptSettings {
|
|
113
|
+
// What Enter answers; without one, Enter waits for y or n.
|
|
114
|
+
default?: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A yes or no, y or n answering it and Enter taking the default; undefined once cancelled.
|
|
118
|
+
export async function confirm(question: string, { default: fallback, ...settings }: ConfirmSettings = {}): Promise<boolean | undefined> {
|
|
119
|
+
let answered = fallback
|
|
120
|
+
const choices = { true: 'Y/n', false: 'y/N', undefined: 'y/n' }[String(fallback) as 'true' | 'false' | 'undefined']
|
|
121
|
+
const accepted = await ask(question, settings, (colors, state) => [state === 'asking' ? asking(colors, question, '', choices) : answer(colors, question, state, answered ? 'yes' : 'no')], (key) => {
|
|
122
|
+
if (key === 'cancel')
|
|
123
|
+
return 'cancel'
|
|
124
|
+
|
|
125
|
+
if (key === 'enter')
|
|
126
|
+
return answered === undefined ? undefined : 'enter'
|
|
127
|
+
|
|
128
|
+
const letter = typeof key === 'object' ? key.text.toLowerCase() : ''
|
|
129
|
+
|
|
130
|
+
if (letter !== 'y' && letter !== 'n')
|
|
131
|
+
return undefined
|
|
132
|
+
|
|
133
|
+
answered = letter === 'y'
|
|
134
|
+
|
|
135
|
+
return 'enter'
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return accepted ? answered : undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface InputPromptSettings extends PromptSettings {
|
|
142
|
+
// What an empty answer stands for, shown dimmed until something is typed.
|
|
143
|
+
default?: string
|
|
144
|
+
// Draws each character typed as `*`, for a password or a token.
|
|
145
|
+
mask?: boolean
|
|
146
|
+
// What is wrong with the answer, shown under it until Enter is pressed on one it takes; undefined takes it.
|
|
147
|
+
validate?: (value: string) => string | undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A line of text, Enter taking it once `validate` does; undefined once cancelled.
|
|
151
|
+
export async function input(question: string, { default: fallback, mask = false, validate, ...settings }: InputPromptSettings = {}): Promise<string | undefined> {
|
|
152
|
+
let value = ''
|
|
153
|
+
let invalid: string | undefined
|
|
154
|
+
const taken = () => value === '' ? fallback ?? '' : value
|
|
155
|
+
const shown = () => mask ? '*'.repeat([...taken()].length) : taken()
|
|
156
|
+
const accepted = await ask(question, settings, (colors, state, { columns }) => {
|
|
157
|
+
if (state !== 'asking')
|
|
158
|
+
return [answer(colors, question, state, shown())]
|
|
159
|
+
|
|
160
|
+
// Its end alone where it would wrap: `? `, the question, a space, the caret, and the last column kept free.
|
|
161
|
+
const characters = [...shown()]
|
|
162
|
+
const room = Math.max(columns - question.length - 5, 1)
|
|
163
|
+
const tail = characters.length > room ? `…${characters.slice(1 - room).join('')}` : shown()
|
|
164
|
+
const typed = value === '' ? colors.dim(tail) : tail
|
|
165
|
+
|
|
166
|
+
return [`${colors.cyan('?')} ${question} ${typed}${colors.dim('█')}`, ...invalid === undefined ? [] : [` ${colors.red(invalid)}`]]
|
|
167
|
+
}, (key) => {
|
|
168
|
+
if (key === 'cancel')
|
|
169
|
+
return 'cancel'
|
|
170
|
+
|
|
171
|
+
invalid = undefined
|
|
172
|
+
|
|
173
|
+
if (key === 'enter') {
|
|
174
|
+
invalid = validate?.(taken())
|
|
175
|
+
|
|
176
|
+
return invalid === undefined ? 'enter' : undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (key === 'erase')
|
|
180
|
+
value = [...value].slice(0, -1).join('')
|
|
181
|
+
else if (typeof key === 'object')
|
|
182
|
+
value += key.text
|
|
183
|
+
|
|
184
|
+
return undefined
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
return accepted ? taken() : undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The choices a filter leaves, the cursor on one of them and the window of rows drawn around it.
|
|
191
|
+
class List<T> {
|
|
192
|
+
query = ''
|
|
193
|
+
private matches: Choice<T>[]
|
|
194
|
+
private cursor: number
|
|
195
|
+
private top = 0
|
|
196
|
+
private readonly width: number
|
|
197
|
+
|
|
198
|
+
constructor(private readonly choices: Choice<T>[], initial: number) {
|
|
199
|
+
this.matches = choices
|
|
200
|
+
this.cursor = Math.min(Math.max(initial, 0), choices.length - 1)
|
|
201
|
+
this.width = Math.max(0, ...choices.map(choice => choice.label.length))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
current(): Choice<T> | undefined {
|
|
205
|
+
return this.matches[this.cursor]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
press(key: Key): void {
|
|
209
|
+
if (key === 'up' || key === 'down') {
|
|
210
|
+
if (this.matches.length > 0)
|
|
211
|
+
this.cursor = (this.cursor + (key === 'up' ? this.matches.length - 1 : 1)) % this.matches.length
|
|
212
|
+
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (key === 'erase')
|
|
217
|
+
this.filter([...this.query].slice(0, -1).join(''))
|
|
218
|
+
else if (typeof key === 'object')
|
|
219
|
+
this.filter(this.query + key.text)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Every word typed is in the label or the hint, case ignored; the cursor stays on its choice while it still matches, else goes to the first.
|
|
223
|
+
private filter(query: string): void {
|
|
224
|
+
const words = query.toLowerCase().split(WORDS).filter(word => word !== '')
|
|
225
|
+
const current = this.current()
|
|
226
|
+
|
|
227
|
+
this.query = query
|
|
228
|
+
this.matches = this.choices.filter(({ label, hint }) => words.every(word => `${label} ${hint}`.toLowerCase().includes(word)))
|
|
229
|
+
this.cursor = current === undefined ? 0 : Math.max(this.matches.indexOf(current), 0)
|
|
230
|
+
this.top = 0
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// A line per choice in the window, each cut to the terminal's width so none wraps, which would throw off the redraw, and a dimmed count of those scrolled past.
|
|
234
|
+
rows(colors: Colors, { columns, rows }: { columns: number, rows: number }, marker: (choice: Choice<T>) => string): string[] {
|
|
235
|
+
if (this.matches.length === 0)
|
|
236
|
+
return [colors.dim(' no match')]
|
|
237
|
+
|
|
238
|
+
const height = Math.max(1, Math.min(MAX_ROWS, rows - 3, this.matches.length))
|
|
239
|
+
|
|
240
|
+
this.top = Math.min(Math.max(this.top, this.cursor - height + 1), this.cursor)
|
|
241
|
+
|
|
242
|
+
const shown = this.matches.slice(this.top, this.top + height).map((choice, index) => {
|
|
243
|
+
const here = this.top + index === this.cursor
|
|
244
|
+
const mark = marker(choice)
|
|
245
|
+
const head = `${here ? '❯' : ' '} ${mark}`
|
|
246
|
+
const label = choice.label.padEnd(this.width)
|
|
247
|
+
// What is left of the line for the hint: the pointer and a space, a mark of two visible characters (`◉ `) when there is one, the label, two spaces, and the last column kept free.
|
|
248
|
+
const room = columns - 5 - (mark === '' ? 0 : 2) - this.width
|
|
249
|
+
|
|
250
|
+
return `${here ? colors.cyan(head) : head}${here ? colors.cyan(label) : label} ${colors.dim(truncate(choice.hint, Math.max(room, 0)))}`
|
|
251
|
+
})
|
|
252
|
+
const below = this.matches.length - this.top - height
|
|
253
|
+
|
|
254
|
+
return [...this.top > 0 ? [colors.dim(` … ${this.top} more`)] : [], ...shown, ...below > 0 ? [colors.dim(` … ${below} more`)] : []]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The question line while asking: what was typed after it, else a dimmed note of the keys.
|
|
259
|
+
function asking(colors: Colors, question: string, typed: string, keys: string): string {
|
|
260
|
+
return `${colors.cyan('?')} ${question} ${typed === '' ? colors.dim(`(${keys})`) : typed}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The line a prompt leaves once answered: what was picked, or that it was cancelled.
|
|
264
|
+
function answer(colors: Colors, question: string, state: Outcome, picked: string): string {
|
|
265
|
+
return state === 'enter' ? `${colors.green('✓')} ${question} ${colors.cyan(picked)}` : `${colors.red('✗')} ${question} ${colors.dim('cancelled')}`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type Render = (colors: Colors, state: State, size: { columns: number, rows: number }) => string[]
|
|
269
|
+
|
|
270
|
+
// Draws a prompt on stderr and redraws it after each key stdin sends raw, until `press` answers `enter` or `cancel`; true once entered. Off a terminal, nobody could answer, so it fails at once.
|
|
271
|
+
async function ask(question: string, { hint, terminal = process }: PromptSettings, render: Render, press: (key: Key) => Outcome | undefined): Promise<boolean> {
|
|
272
|
+
const { stdin, stderr } = terminal
|
|
273
|
+
|
|
274
|
+
if (stdin.isTTY !== true || stderr.isTTY !== true)
|
|
275
|
+
fail(`cannot ask "${question}" off a terminal`, 2, hint)
|
|
276
|
+
|
|
277
|
+
const colors = paint(stderr)
|
|
278
|
+
let drawn = 0
|
|
279
|
+
let outcome: Outcome | undefined
|
|
280
|
+
const draw = (state: State) => {
|
|
281
|
+
// A terminal that does not say its size, as a pseudo-terminal may, answers 0.
|
|
282
|
+
const frame = render(colors, state, { columns: stderr.columns || 80, rows: stderr.rows || 24 })
|
|
283
|
+
|
|
284
|
+
stderr.write(`${drawn > 1 ? `\x1B[${drawn - 1}A` : ''}\r\x1B[0J${frame.join('\n')}`)
|
|
285
|
+
drawn = frame.length
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
stdin.setRawMode(true)
|
|
290
|
+
stdin.resume()
|
|
291
|
+
stderr.write('\x1B[?25l')
|
|
292
|
+
draw('asking')
|
|
293
|
+
|
|
294
|
+
return await new Promise<boolean>((resolve) => {
|
|
295
|
+
function onData(data: Buffer) {
|
|
296
|
+
for (const key of readKeys(data.toString())) {
|
|
297
|
+
const pressed = press(key)
|
|
298
|
+
|
|
299
|
+
if (pressed !== undefined) {
|
|
300
|
+
settle(pressed)
|
|
301
|
+
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
draw('asking')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// A stdin closing before an answer, as a terminal hung up does, cancels instead of leaving the prompt waiting forever.
|
|
310
|
+
function onEnd() {
|
|
311
|
+
settle('cancel')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function settle(settled: Outcome) {
|
|
315
|
+
outcome = settled
|
|
316
|
+
stdin.off('data', onData)
|
|
317
|
+
stdin.off('end', onEnd)
|
|
318
|
+
resolve(settled === 'enter')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
stdin.on('data', onData)
|
|
322
|
+
stdin.on('end', onEnd)
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
draw(outcome ?? 'cancel')
|
|
327
|
+
stderr.write('\n\x1B[?25h')
|
|
328
|
+
stdin.setRawMode(false)
|
|
329
|
+
stdin.pause()
|
|
330
|
+
}
|
|
331
|
+
}
|