UNPKG

7.63 kBJavaScriptView Raw
1'use strict';
2const readline = require('readline');
3const chalk = require('chalk');
4const cliCursor = require('cli-cursor');
5const cliSpinners = require('cli-spinners');
6const logSymbols = require('log-symbols');
7const stripAnsi = require('strip-ansi');
8const wcwidth = require('wcwidth');
9const isInteractive = require('is-interactive');
10const MuteStream = require('mute-stream');
11
12const TEXT = Symbol('text');
13const PREFIX_TEXT = Symbol('prefixText');
14
15const ASCII_ETX_CODE = 0x03; // Ctrl+C emits this code
16
17class StdinDiscarder {
18 constructor() {
19 this.requests = 0;
20
21 this.mutedStream = new MuteStream();
22 this.mutedStream.pipe(process.stdout);
23 this.mutedStream.mute();
24
25 const self = this;
26 this.ourEmit = function (event, data, ...args) {
27 const {stdin} = process;
28 if (self.requests > 0 || stdin.emit === self.ourEmit) {
29 if (event === 'keypress') { // Fixes readline behavior
30 return;
31 }
32
33 if (event === 'data' && data.includes(ASCII_ETX_CODE)) {
34 process.emit('SIGINT');
35 }
36
37 Reflect.apply(self.oldEmit, this, [event, data, ...args]);
38 } else {
39 Reflect.apply(process.stdin.emit, this, [event, data, ...args]);
40 }
41 };
42 }
43
44 start() {
45 this.requests++;
46
47 if (this.requests === 1) {
48 this.realStart();
49 }
50 }
51
52 stop() {
53 if (this.requests <= 0) {
54 throw new Error('`stop` called more times than `start`');
55 }
56
57 this.requests--;
58
59 if (this.requests === 0) {
60 this.realStop();
61 }
62 }
63
64 realStart() {
65 // No known way to make it work reliably on Windows
66 if (process.platform === 'win32') {
67 return;
68 }
69
70 this.rl = readline.createInterface({
71 input: process.stdin,
72 output: this.mutedStream
73 });
74
75 this.rl.on('SIGINT', () => {
76 if (process.listenerCount('SIGINT') === 0) {
77 process.emit('SIGINT');
78 } else {
79 this.rl.close();
80 process.kill(process.pid, 'SIGINT');
81 }
82 });
83 }
84
85 realStop() {
86 if (process.platform === 'win32') {
87 return;
88 }
89
90 this.rl.close();
91 this.rl = undefined;
92 }
93}
94
95const stdinDiscarder = new StdinDiscarder();
96
97class Ora {
98 constructor(options) {
99 if (typeof options === 'string') {
100 options = {
101 text: options
102 };
103 }
104
105 this.options = {
106 text: '',
107 color: 'cyan',
108 stream: process.stderr,
109 discardStdin: true,
110 ...options
111 };
112
113 this.spinner = this.options.spinner;
114
115 this.color = this.options.color;
116 this.hideCursor = this.options.hideCursor !== false;
117 this.interval = this.options.interval || this.spinner.interval || 100;
118 this.stream = this.options.stream;
119 this.id = undefined;
120 this.isEnabled = typeof this.options.isEnabled === 'boolean' ? this.options.isEnabled : isInteractive({stream: this.stream});
121
122 // Set *after* `this.stream`
123 this.text = this.options.text;
124 this.prefixText = this.options.prefixText;
125 this.linesToClear = 0;
126 this.indent = this.options.indent;
127 this.discardStdin = this.options.discardStdin;
128 this.isDiscardingStdin = false;
129 }
130
131 get indent() {
132 return this._indent;
133 }
134
135 set indent(indent = 0) {
136 if (!(indent >= 0 && Number.isInteger(indent))) {
137 throw new Error('The `indent` option must be an integer from 0 and up');
138 }
139
140 this._indent = indent;
141 }
142
143 _updateInterval(interval) {
144 if (interval !== undefined) {
145 this.interval = interval;
146 }
147 }
148
149 get spinner() {
150 return this._spinner;
151 }
152
153 set spinner(spinner) {
154 this.frameIndex = 0;
155
156 if (typeof spinner === 'object') {
157 if (spinner.frames === undefined) {
158 throw new Error('The given spinner must have a `frames` property');
159 }
160
161 this._spinner = spinner;
162 } else if (process.platform === 'win32') {
163 this._spinner = cliSpinners.line;
164 } else if (spinner === undefined) {
165 // Set default spinner
166 this._spinner = cliSpinners.dots;
167 } else if (cliSpinners[spinner]) {
168 this._spinner = cliSpinners[spinner];
169 } else {
170 throw new Error(`There is no built-in spinner named '${spinner}'. See https://github.com/sindresorhus/cli-spinners/blob/master/spinners.json for a full list.`);
171 }
172
173 this._updateInterval(this._spinner.interval);
174 }
175
176 get text() {
177 return this[TEXT];
178 }
179
180 get prefixText() {
181 return this[PREFIX_TEXT];
182 }
183
184 get isSpinning() {
185 return this.id !== undefined;
186 }
187
188 updateLineCount() {
189 const columns = this.stream.columns || 80;
190 const fullPrefixText = (typeof this[PREFIX_TEXT] === 'string') ? this[PREFIX_TEXT] + '-' : '';
191 this.lineCount = stripAnsi(fullPrefixText + '--' + this[TEXT]).split('\n').reduce((count, line) => {
192 return count + Math.max(1, Math.ceil(wcwidth(line) / columns));
193 }, 0);
194 }
195
196 set text(value) {
197 this[TEXT] = value;
198 this.updateLineCount();
199 }
200
201 set prefixText(value) {
202 this[PREFIX_TEXT] = value;
203 this.updateLineCount();
204 }
205
206 frame() {
207 const {frames} = this.spinner;
208 let frame = frames[this.frameIndex];
209
210 if (this.color) {
211 frame = chalk[this.color](frame);
212 }
213
214 this.frameIndex = ++this.frameIndex % frames.length;
215 const fullPrefixText = (typeof this.prefixText === 'string' && this.prefixText !== '') ? this.prefixText + ' ' : '';
216 const fullText = typeof this.text === 'string' ? ' ' + this.text : '';
217
218 return fullPrefixText + frame + fullText;
219 }
220
221 clear() {
222 if (!this.isEnabled || !this.stream.isTTY) {
223 return this;
224 }
225
226 for (let i = 0; i < this.linesToClear; i++) {
227 if (i > 0) {
228 this.stream.moveCursor(0, -1);
229 }
230
231 this.stream.clearLine();
232 this.stream.cursorTo(this.indent);
233 }
234
235 this.linesToClear = 0;
236
237 return this;
238 }
239
240 render() {
241 this.clear();
242 this.stream.write(this.frame());
243 this.linesToClear = this.lineCount;
244
245 return this;
246 }
247
248 start(text) {
249 if (text) {
250 this.text = text;
251 }
252
253 if (!this.isEnabled) {
254 if (this.text) {
255 this.stream.write(`- ${this.text}\n`);
256 }
257
258 return this;
259 }
260
261 if (this.isSpinning) {
262 return this;
263 }
264
265 if (this.hideCursor) {
266 cliCursor.hide(this.stream);
267 }
268
269 if (this.discardStdin && process.stdin.isTTY) {
270 this.isDiscardingStdin = true;
271 stdinDiscarder.start();
272 }
273
274 this.render();
275 this.id = setInterval(this.render.bind(this), this.interval);
276
277 return this;
278 }
279
280 stop() {
281 if (!this.isEnabled) {
282 return this;
283 }
284
285 clearInterval(this.id);
286 this.id = undefined;
287 this.frameIndex = 0;
288 this.clear();
289 if (this.hideCursor) {
290 cliCursor.show(this.stream);
291 }
292
293 if (this.discardStdin && process.stdin.isTTY && this.isDiscardingStdin) {
294 stdinDiscarder.stop();
295 this.isDiscardingStdin = false;
296 }
297
298 return this;
299 }
300
301 succeed(text) {
302 return this.stopAndPersist({symbol: logSymbols.success, text});
303 }
304
305 fail(text) {
306 return this.stopAndPersist({symbol: logSymbols.error, text});
307 }
308
309 warn(text) {
310 return this.stopAndPersist({symbol: logSymbols.warning, text});
311 }
312
313 info(text) {
314 return this.stopAndPersist({symbol: logSymbols.info, text});
315 }
316
317 stopAndPersist(options = {}) {
318 const prefixText = options.prefixText || this.prefixText;
319 const fullPrefixText = (typeof prefixText === 'string' && prefixText !== '') ? prefixText + ' ' : '';
320 const text = options.text || this.text;
321 const fullText = (typeof text === 'string') ? ' ' + text : '';
322
323 this.stop();
324 this.stream.write(`${fullPrefixText}${options.symbol || ' '}${fullText}\n`);
325
326 return this;
327 }
328}
329
330const oraFactory = function (options) {
331 return new Ora(options);
332};
333
334module.exports = oraFactory;
335
336module.exports.promise = (action, options) => {
337 // eslint-disable-next-line promise/prefer-await-to-then
338 if (typeof action.then !== 'function') {
339 throw new TypeError('Parameter `action` must be a Promise');
340 }
341
342 const spinner = new Ora(options);
343 spinner.start();
344
345 (async () => {
346 try {
347 await action;
348 spinner.succeed();
349 } catch (_) {
350 spinner.fail();
351 }
352 })();
353
354 return spinner;
355};