codekingpro/portable-devtools
114k
1const { log, output, input, META } = require('proc-log')
2const { explain } = require('./explain-eresolve.js')
3const { formatWithOptions } = require('./format')
4
5// This is the general approach to color:
6// Eventually this will be exposed somewhere we can refer to these by name.
7// Foreground colors only. Never set the background color.
8/*
9 * Black # (Don't use)
10 * Red # Danger
11 * Green # Success
12 * Yellow # Warning
13 * Blue # Accent
14 * Magenta # Done
15 * Cyan # Emphasis
16 * White # (Don't use)
17 */
18
19// Translates log levels to chalk colors
20const COLOR_PALETTE = ({ chalk: c }) => ({
21 heading: c.bold,
22 title: c.blueBright,
23 timing: c.magentaBright,
24 // loglevels
25 error: c.red,
26 warn: c.yellow,
27 notice: c.cyanBright,
28 http: c.green,
29 info: c.cyan,
30 verbose: c.blue,
31 silly: c.blue.dim,
32})
33
34const LEVEL_OPTIONS = {
35 silent: {
36 index: 0,
37 },
38 error: {
39 index: 1,
40 },
41 warn: {
42 index: 2,
43 },
44 notice: {
45 index: 3,
46 },
47 http: {
48 index: 4,
49 },
50 info: {
51 index: 5,
52 },
53 verbose: {
54 index: 6,
55 },
56 silly: {
57 index: 7,
58 },
59}
60
61const LEVEL_METHODS = {
62 ...LEVEL_OPTIONS,
63 [log.KEYS.timing]: {
64 show: ({ timing, index }) => !!timing && index !== 0,
65 },
66}
67
68const setBlocking = (stream) => {
69 // Copied from https://github.com/yargs/set-blocking
70 // https://raw.githubusercontent.com/yargs/set-blocking/master/LICENSE.txt
71 /* istanbul ignore next - we trust that this works */
72 if (stream._handle && stream.isTTY && typeof stream._handle.setBlocking === 'function') {
73 stream._handle.setBlocking(true)
74 }
75 return stream
76}
77
78// This is the key that is returned to the user for errors
79const ERROR_KEY = 'error'
80// This is the key producers use to indicate that there is a json error that should be merged into the finished output
81const JSON_ERROR_KEY = 'jsonError'
82
83const isPlainObject = (v) => v && typeof v === 'object' && !Array.isArray(v)
84
85const getArrayOrObject = (items) => {
86 if (items.length) {
87 const foundNonObject = items.find(o => !isPlainObject(o))
88 // Non-objects and arrays cant be merged, so just return the first item
89 if (foundNonObject) {
90 return foundNonObject
91 }
92 // We use objects with 0,1,2,etc keys to merge array
93 if (items.every((o, i) => Object.hasOwn(o, i))) {
94 return Object.assign([], ...items)
95 }
96 }
97 // Otherwise its an object with all object items merged together
98 return Object.assign({}, ...items.filter(o => isPlainObject(o)))
99}
100
101const getJsonBuffer = ({ [JSON_ERROR_KEY]: metaError }, buffer) => {
102 const items = []
103 // meta also contains the meta object passed to flush
104 const errors = metaError ? [metaError] : []
105 // index 1 is the meta, 2 is the logged argument
106 for (const [, { [JSON_ERROR_KEY]: error }, obj] of buffer) {
107 if (obj) {
108 items.push(obj)
109 }
110 if (error) {
111 errors.push(error)
112 }
113 }
114
115 if (!items.length && !errors.length) {
116 return null
117 }
118
119 const res = getArrayOrObject(items)
120
121 // This skips any error checking since we can only set an error property on an object that can be stringified
122 // XXX(BREAKING_CHANGE): remove this in favor of always returning an object with result and error keys
123 if (isPlainObject(res) && errors.length) {
124 // This is not ideal.
125 // JSON output has always been keyed at the root with an `error` key, so we cant change that without it being a breaking change. At the same time some commands output arbitrary keys at the top level of the output, such as package names.
126 // So the output could already have the same key. The choice here is to overwrite it with our error since that is (probably?) more important.
127 // XXX(BREAKING_CHANGE): all json output should be keyed under well known keys, eg `result` and `error`
128 if (res[ERROR_KEY]) {
129 log.warn('', `overwriting existing ${ERROR_KEY} on json output`)
130 }
131 res[ERROR_KEY] = getArrayOrObject(errors)
132 }
133
134 return res
135}
136
137const withMeta = (handler) => (level, ...args) => {
138 let meta = {}
139 const last = args.at(-1)
140 if (last && typeof last === 'object' && Object.hasOwn(last, META)) {
141 meta = args.pop()
142 }
143 return handler(level, meta, ...args)
144}
145
146class Display {
147 #logState = {
148 buffering: true,
149 buffer: [],
150 }
151
152 #outputState = {
153 buffering: true,
154 buffer: [],
155 }
156
157 // colors
158 #noColorChalk
159 #stdoutChalk
160 #stdoutColor
161 #stderrChalk
162 #stderrColor
163 #logColors
164
165 // progress
166 #progress
167
168 // options
169 #command
170 #levelIndex
171 #timing
172 #json
173 #heading
174 #silent
175
176 // display streams
177 #stdout
178 #stderr
179
180 #seenNotices = new Set()
181
182 constructor ({ stdout, stderr }) {
183 this.#stdout = setBlocking(stdout)
184 this.#stderr = setBlocking(stderr)
185
186 // Handlers are set immediately so they can buffer all events
187 process.on('log', this.#logHandler)
188 process.on('output', this.#outputHandler)
189 process.on('input', this.#inputHandler)
190 this.#progress = new Progress({ stream: stderr })
191 }
192
193 off () {
194 process.off('log', this.#logHandler)
195 this.#logState.buffer.length = 0
196 process.off('output', this.#outputHandler)
197 this.#outputState.buffer.length = 0
198 process.off('input', this.#inputHandler)
199 this.#progress.off()
200 this.#seenNotices.clear()
201 }
202
203 get chalk () {
204 return {
205 noColor: this.#noColorChalk,
206 stdout: this.#stdoutChalk,
207 stderr: this.#stderrChalk,
208 }
209 }
210
211 async load ({
212 command,
213 heading,
214 json,
215 loglevel,
216 progress,
217 stderrColor,
218 stdoutColor,
219 timing,
220 unicode,
221 }) {
222 const [{ Chalk }, { createSupportsColor }] = await Promise.all([
223 import('chalk'),
224 import('supports-color'),
225 ])
226 // We get the chalk level based on a null stream, meaning chalk will only use what it knows about the environment to get color support since we already determined in our definitions that we want to show colors.
227 const level = Math.max(createSupportsColor(null).level, 1)
228 this.#noColorChalk = new Chalk({ level: 0 })
229 this.#stdoutColor = stdoutColor
230 this.#stdoutChalk = stdoutColor ? new Chalk({ level }) : this.#noColorChalk
231 this.#stderrColor = stderrColor
232 this.#stderrChalk = stderrColor ? new Chalk({ level }) : this.#noColorChalk
233 this.#logColors = COLOR_PALETTE({ chalk: this.#stderrChalk })
234
235 this.#command = command
236 this.#levelIndex = LEVEL_OPTIONS[loglevel].index
237 this.#timing = timing
238 this.#json = json
239 this.#heading = heading
240 this.#silent = this.#levelIndex <= 0
241
242 // Emit resume event on the logs which will flush output
243 log.resume()
244 output.flush()
245 this.#progress.load({
246 unicode,
247 enabled: !!progress && !this.#silent,
248 })
249 }
250
251 // STREAM WRITES
252
253 // Write formatted and (non-)colorized output to streams
254 #write (stream, options, ...args) {
255 const colors = stream === this.#stdout ? this.#stdoutColor : this.#stderrColor
256 const value = formatWithOptions({ colors, ...options }, ...args)
257 this.#progress.write(() => stream.write(value))
258 }
259
260 // HANDLERS
261
262 // Arrow function assigned to a private class field so it can be passed directly as a listener and still reference "this"
263 #logHandler = withMeta((level, meta, ...args) => {
264 switch (level) {
265 case log.KEYS.resume:
266 this.#logState.buffering = false
267 this.#logState.buffer.forEach((item) => this.#tryWriteLog(...item))
268 this.#logState.buffer.length = 0
269 break
270
271 case log.KEYS.pause:
272 this.#logState.buffering = true
273 break
274
275 default:
276 if (this.#logState.buffering) {
277 this.#logState.buffer.push([level, meta, ...args])
278 } else {
279 this.#tryWriteLog(level, meta, ...args)
280 }
281 break
282 }
283 })
284
285 // Arrow function assigned to a private class field so it can be passed directly as a listener and still reference "this"
286 #outputHandler = withMeta((level, meta, ...args) => {
287 this.#json = typeof meta.json === 'boolean' ? meta.json : this.#json
288 switch (level) {
289 case output.KEYS.flush: {
290 this.#outputState.buffering = false
291 if (this.#json) {
292 const json = getJsonBuffer(meta, this.#outputState.buffer)
293 if (json) {
294 this.#writeOutput(output.KEYS.standard, meta, JSON.stringify(json, null, 2))
295 }
296 } else {
297 this.#outputState.buffer.forEach((item) => this.#writeOutput(...item))
298 }
299 this.#outputState.buffer.length = 0
300 break
301 }
302
303 case output.KEYS.buffer:
304 this.#outputState.buffer.push([output.KEYS.standard, meta, ...args])
305 break
306
307 default:
308 if (this.#outputState.buffering) {
309 this.#outputState.buffer.push([level, meta, ...args])
310 } else {
311 // XXX: Check if the argument looks like a run-script banner. This should be replaced with proc-log.META in @npmcli/run-script
312 if (typeof args[0] === 'string' && args[0].startsWith('\n> ') && args[0].endsWith('\n')) {
313 if (this.#silent || ['exec', 'explore'].includes(this.#command)) {
314 // Silent mode and some specific commands always hide run script banners
315 break
316 } else if (this.#json) {
317 // In json mode, change output to stderr since we don't want to break json parsing on stdout if the user is piping to jq or something.
318 // XXX: in a future (breaking?) change it might make sense for run-script to always output these banners with proc-log.output.error if we think they align closer with "logging" instead of "output".
319 level = output.KEYS.error
320 }
321 }
322 this.#writeOutput(level, meta, ...args)
323 }
324 break
325 }
326 })
327
328 #inputHandler = withMeta((level, meta, ...args) => {
329 switch (level) {
330 case input.KEYS.start:
331 log.pause()
332 this.#outputState.buffering = true
333 this.#progress.off()
334 break
335
336 case input.KEYS.end: {
337 log.resume()
338 // For silent prompts (like password), add newline to preserve output
339 if (meta?.silent) {
340 output.standard()
341 }
342 output.flush()
343 this.#progress.resume()
344 break
345 }
346
347 case input.KEYS.read: {
348 // The convention when calling input.read is to pass in a single fn that returns the promise to await. Resolve and reject are provided by proc-log.
349 const [res, rej, p] = args
350
351 // Use sequential input management to avoid race condition which causes issues with spinner and adding newlines.
352 input.start()
353
354 return p()
355 .then((result) => {
356 // If user hits enter, process end event and return input.
357 input.end({ [META]: true, silent: meta?.silent })
358 res(result)
359 return result
360 })
361 .catch((error) => {
362 // If user hits ctrl+c, add newline to preserve output.
363 output.standard()
364 input.end()
365 rej(error)
366 })
367 }
368 }
369 })
370
371 // OUTPUT
372
373 #writeOutput (level, meta, ...args) {
374 switch (level) {
375 case output.KEYS.standard:
376 this.#write(this.#stdout, meta, ...args)
377 break
378
379 case output.KEYS.error:
380 this.#write(this.#stderr, meta, ...args)
381 break
382 }
383 }
384
385 // LOGS
386
387 #tryWriteLog (level, meta, ...args) {
388 try {
389 // Also (and this is a really inexcusable kludge), we patch the log.warn() method so that when we see a peerDep override explanation from Arborist, we can replace the object with a highly abbreviated explanation of what's being overridden.
390 // TODO: this could probably be moved to arborist now that display is refactored
391 const [heading, message, expl] = args
392 if (level === log.KEYS.warn && heading === 'ERESOLVE' && expl && typeof expl === 'object') {
393 this.#writeLog(level, meta, heading, message)
394 this.#writeLog(level, meta, '', explain(expl, this.#stderrChalk, 2))
395 return
396 }
397 this.#writeLog(level, meta, ...args)
398 } catch (ex) {
399 try {
400 // if it crashed once, it might again!
401 this.#writeLog(log.KEYS.verbose, meta, '', `attempt to log crashed`, ...args, ex)
402 } catch (ex2) {
403 // This happens if the object has an inspect method that crashes so just console.error with the errors but don't do anything else that might error again.
404 // eslint-disable-next-line no-console
405 console.error(`attempt to log crashed`, ex, ex2)
406 }
407 }
408 }
409
410 #writeLog (level, meta, ...args) {
411 const levelOpts = LEVEL_METHODS[level]
412 const show = levelOpts.show ?? (({ index }) => levelOpts.index <= index)
413 const force = meta.force && !this.#silent
414
415 if (force || show({ index: this.#levelIndex, timing: this.#timing })) {
416 // this mutates the array so we can pass args directly to format later
417 const title = args.shift()
418 const prefix = [
419 this.#logColors.heading(this.#heading),
420 this.#logColors[level](level),
421 title ? this.#logColors.title(title) : null,
422 ]
423 const writeOpts = { prefix }
424 // notice logs typically come from `npm-notice` headers in responses. Some of them have 2fa login links so we skip redaction.
425 if (level === 'notice') {
426 writeOpts.redact = false
427 // Deduplicate notices within a single command execution, unless in verbose mode
428 if (this.#levelIndex < LEVEL_OPTIONS.verbose.index) {
429 const noticeKey = JSON.stringify([title, ...args])
430 if (this.#seenNotices.has(noticeKey)) {
431 return
432 }
433 this.#seenNotices.add(noticeKey)
434 }
435 }
436 this.#write(this.#stderr, writeOpts, ...args)
437 }
438 }
439}
440
441class Progress {
442 // Taken from https://github.com/sindresorhus/cli-spinners
443 // MIT License
444 // Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
445 static dots = { duration: 80, frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] }
446 static lines = { duration: 130, frames: ['-', '\\', '|', '/'] }
447
448 #enabled = false
449 #frameIndex = 0
450 #interval
451 #lastUpdate = 0
452 #spinner
453 #stream
454 // Initial timeout to wait to start rendering
455 #timeout
456 #rendered = false
457
458 // We are rendering if enabled option is set and we are not waiting for the render timeout
459 get #rendering () {
460 return this.#enabled && !this.#timeout
461 }
462
463 // We are spinning if enabled option is set and the render interval has been set
464 get #spinning () {
465 return this.#enabled && this.#interval
466 }
467
468 constructor ({ stream }) {
469 this.#stream = stream
470 }
471
472 load ({ enabled, unicode }) {
473 this.#enabled = enabled
474 this.#spinner = unicode ? Progress.dots : Progress.lines
475 // Wait 200 ms so we don't render the spinner for short durations
476 this.#timeout = setTimeout(() => {
477 this.#timeout = null
478 this.#render()
479 }, 200)
480 // Make sure this timeout does not keep the process open
481 this.#timeout.unref()
482 }
483
484 off () {
485 if (!this.#enabled) {
486 return
487 }
488 clearTimeout(this.#timeout)
489 this.#timeout = null
490 clearInterval(this.#interval)
491 this.#interval = null
492 this.#frameIndex = 0
493 this.#lastUpdate = 0
494 this.#clearSpinner()
495 }
496
497 resume () {
498 this.#render(true)
499 }
500
501 // If we are currently rendering the spinner we clear it before writing our line and then re-render the spinner after.
502 // If not then all we need to do is write the line.
503 write (write) {
504 if (this.#spinning) {
505 this.#clearSpinner()
506 }
507 write()
508 if (this.#spinning) {
509 this.#render()
510 }
511 }
512
513 #render (resuming) {
514 if (!this.#rendering) {
515 return
516 }
517 // We always attempt to render immediately but we only request to move to the next frame if it has been longer than our spinner frame duration since our last update
518 this.#renderFrame(Date.now() - this.#lastUpdate >= this.#spinner.duration, resuming)
519 if (!this.#interval) {
520 this.#interval = setInterval(() => this.#renderFrame(true), this.#spinner.duration)
521 // Make sure this timeout does not keep the process open
522 this.#interval.unref()
523 }
524 this.#interval.refresh()
525 }
526
527 #renderFrame (next, resuming) {
528 if (next) {
529 this.#lastUpdate = Date.now()
530 this.#frameIndex++
531 if (this.#frameIndex >= this.#spinner.frames.length) {
532 this.#frameIndex = 0
533 }
534 }
535 if (!resuming) {
536 this.#clearSpinner()
537 }
538 this.#stream.write(this.#spinner.frames[this.#frameIndex])
539 this.#rendered = true
540 }
541
542 #clearSpinner () {
543 if (!this.#rendered) {
544 return
545 }
546 // Move to the start of the line and clear the rest of the line
547 this.#stream.cursorTo(0)
548 this.#stream.clearLine(1)
549 this.#rendered = false
550 }
551}
552
553module.exports = Display
554 