Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
display.js554 linesDownload Raw Back to utils
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 
codekingpro/portable-devtools · Team Ai