-
Notifications
You must be signed in to change notification settings - Fork 80
Expand file tree
/
Copy pathcli.js
More file actions
160 lines (138 loc) · 3.58 KB
/
Copy pathcli.js
File metadata and controls
160 lines (138 loc) · 3.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import readline from 'node:readline'
const SPINNER_FRAMES = [
'☀️',
'☀️',
'☀️',
'🌤',
'🌥',
'☁️',
'🌧',
'🌨',
'🌧',
'🌨',
'🌧',
'🌨',
'⛈️',
'🌨',
'🌧',
'🌨',
'☁️',
'🌥',
'🌤',
'☀️',
'☀️',
]
/**
* Creates a loading spinner with a message
* @param {string} message
* @returns {{ stop: () => void }}
*/
export function spinner(message) {
let playing = true
let frame = 0
const interval = setInterval(() => {
process.stdout.write(`\r${SPINNER_FRAMES[frame].padEnd(2)} ${message}`)
frame = (frame + 1) % SPINNER_FRAMES.length
}, 100)
return {
stop: () => {
if (!playing) return
playing = false
clearInterval(interval)
process.stdout.write('\r' + ' '.repeat(message.length + 4) + '\r') // +4 for emoji + spaces
},
}
}
/**
* Returns a string wrapped in ANSI escape codes for gray text.
* @param {string} text
*/
export function gray(text) {
return `\x1b[90m${text}\x1b[0m`
}
/**
* Returns a string wrapped in ANSI escape codes for bold text.
* @param {string} text
*/
export function bold(text) {
return `\x1b[1m${text}\x1b[0m`
}
export function question(prompt, defaultValue = '') {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
rl.question(prompt, (result) => {
rl.close()
resolve(result || defaultValue)
})
})
}
/**
* Creates an interactive multiple choice selection menu without clearing console
* @param {string} prompt - The question to display above choices
* @param {string[]} choices - Array of options user can choose from
* @returns {Promise<string>} - Returns selected choice
*/
export function select(prompt, choices) {
return new Promise((resolve) => {
let selected = 0
let lines = 0 // track number of lines we've rendered
function render() {
// move cursor up to clear previous render
if (lines > 0) process.stdout.write(`\x1B[${lines}A`)
// render prompt and choices
console.log(bold(prompt) + '\n')
choices.forEach((choice, i) => {
const indicator = i === selected ? '❯' : ' '
console.log(`${indicator} ${choice}`)
})
// Ssore number of lines we just rendered
lines = choices.length + 2 // +2 for prompt and blank line
}
function cleanup() {
// remove event listener
process.stdin.off('data', onData)
// clean up the menu
process.stdout.write(`\x1B[${lines}A`) // Move up
process.stdout.write(`\x1B[J`) // Clear to bottom
// restore cursor and normal mode
process.stdout.write('\x1B[?25h')
process.stdin.setRawMode(false)
process.stdin.pause()
}
/** @param {Buffer} data */
function onData(data) {
const key = data.toString()
switch (key) {
case '\u001b[A': {
selected = (selected + choices.length - 1) % choices.length
render()
break
}
case '\u001b[B': {
selected = (selected + 1) % choices.length
render()
break
}
case '\r': {
cleanup()
console.log(bold(prompt) + ' ' + choices[selected])
resolve(choices[selected])
break
}
case '\u0003': {
cleanup()
return process.exit(1)
}
}
}
// hide cursor and enable raw mode
process.stdout.write('\x1B[?25l')
process.stdin.setRawMode(true)
process.stdin.resume()
render()
process.stdin.on('data', onData)
})
}