| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- const { log, output, input, META } = require('proc-log')
- const { explain } = require('./explain-eresolve.js')
- const { formatWithOptions } = require('./format')
- // This is the general approach to color:
- // Eventually this will be exposed somewhere we can refer to these by name.
- // Foreground colors only. Never set the background color.
- /*
- * Black # (Don't use)
- * Red # Danger
- * Green # Success
- * Yellow # Warning
- * Blue # Accent
- * Magenta # Done
- * Cyan # Emphasis
- * White # (Don't use)
- */
- // Translates log levels to chalk colors
- const COLOR_PALETTE = ({ chalk: c }) => ({
- heading: c.bold,
- title: c.blueBright,
- timing: c.magentaBright,
- // loglevels
- error: c.red,
- warn: c.yellow,
- notice: c.cyanBright,
- http: c.green,
- info: c.cyan,
- verbose: c.blue,
- silly: c.blue.dim,
- })
- const LEVEL_OPTIONS = {
- silent: {
- index: 0,
- },
- error: {
- index: 1,
- },
- warn: {
- index: 2,
- },
- notice: {
- index: 3,
- },
- http: {
- index: 4,
- },
- info: {
- index: 5,
- },
- verbose: {
- index: 6,
- },
- silly: {
- index: 7,
- },
- }
- const LEVEL_METHODS = {
- ...LEVEL_OPTIONS,
- [log.KEYS.timing]: {
- show: ({ timing, index }) => !!timing && index !== 0,
- },
- }
- const setBlocking = (stream) => {
- // Copied from https://github.com/yargs/set-blocking
- // https://raw.githubusercontent.com/yargs/set-blocking/master/LICENSE.txt
- /* istanbul ignore next - we trust that this works */
- if (stream._handle && stream.isTTY && typeof stream._handle.setBlocking === 'function') {
- stream._handle.setBlocking(true)
- }
- return stream
- }
- // This is the key that is returned to the user for errors
- const ERROR_KEY = 'error'
- // This is the key producers use to indicate that there is a json error that should be merged into the finished output
- const JSON_ERROR_KEY = 'jsonError'
- const isPlainObject = (v) => v && typeof v === 'object' && !Array.isArray(v)
- const getArrayOrObject = (items) => {
- if (items.length) {
- const foundNonObject = items.find(o => !isPlainObject(o))
- // Non-objects and arrays cant be merged, so just return the first item
- if (foundNonObject) {
- return foundNonObject
- }
- // We use objects with 0,1,2,etc keys to merge array
- if (items.every((o, i) => Object.hasOwn(o, i))) {
- return Object.assign([], ...items)
- }
- }
- // Otherwise its an object with all object items merged together
- return Object.assign({}, ...items.filter(o => isPlainObject(o)))
- }
- const getJsonBuffer = ({ [JSON_ERROR_KEY]: metaError }, buffer) => {
- const items = []
- // meta also contains the meta object passed to flush
- const errors = metaError ? [metaError] : []
- // index 1 is the meta, 2 is the logged argument
- for (const [, { [JSON_ERROR_KEY]: error }, obj] of buffer) {
- if (obj) {
- items.push(obj)
- }
- if (error) {
- errors.push(error)
- }
- }
- if (!items.length && !errors.length) {
- return null
- }
- const res = getArrayOrObject(items)
- // This skips any error checking since we can only set an error property on an object that can be stringified
- // XXX(BREAKING_CHANGE): remove this in favor of always returning an object with result and error keys
- if (isPlainObject(res) && errors.length) {
- // This is not ideal.
- // 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.
- // 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.
- // XXX(BREAKING_CHANGE): all json output should be keyed under well known keys, eg `result` and `error`
- if (res[ERROR_KEY]) {
- log.warn('', `overwriting existing ${ERROR_KEY} on json output`)
- }
- res[ERROR_KEY] = getArrayOrObject(errors)
- }
- return res
- }
- const withMeta = (handler) => (level, ...args) => {
- let meta = {}
- const last = args.at(-1)
- if (last && typeof last === 'object' && Object.hasOwn(last, META)) {
- meta = args.pop()
- }
- return handler(level, meta, ...args)
- }
- class Display {
- #logState = {
- buffering: true,
- buffer: [],
- }
- #outputState = {
- buffering: true,
- buffer: [],
- }
- // colors
- #noColorChalk
- #stdoutChalk
- #stdoutColor
- #stderrChalk
- #stderrColor
- #logColors
- // progress
- #progress
- // options
- #command
- #levelIndex
- #timing
- #json
- #heading
- #silent
- // display streams
- #stdout
- #stderr
- #seenNotices = new Set()
- constructor ({ stdout, stderr }) {
- this.#stdout = setBlocking(stdout)
- this.#stderr = setBlocking(stderr)
- // Handlers are set immediately so they can buffer all events
- process.on('log', this.#logHandler)
- process.on('output', this.#outputHandler)
- process.on('input', this.#inputHandler)
- this.#progress = new Progress({ stream: stderr })
- }
- off () {
- process.off('log', this.#logHandler)
- this.#logState.buffer.length = 0
- process.off('output', this.#outputHandler)
- this.#outputState.buffer.length = 0
- process.off('input', this.#inputHandler)
- this.#progress.off()
- this.#seenNotices.clear()
- }
- get chalk () {
- return {
- noColor: this.#noColorChalk,
- stdout: this.#stdoutChalk,
- stderr: this.#stderrChalk,
- }
- }
- async load ({
- command,
- heading,
- json,
- loglevel,
- progress,
- stderrColor,
- stdoutColor,
- timing,
- unicode,
- }) {
- const [{ Chalk }, { createSupportsColor }] = await Promise.all([
- import('chalk'),
- import('supports-color'),
- ])
- // 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.
- const level = Math.max(createSupportsColor(null).level, 1)
- this.#noColorChalk = new Chalk({ level: 0 })
- this.#stdoutColor = stdoutColor
- this.#stdoutChalk = stdoutColor ? new Chalk({ level }) : this.#noColorChalk
- this.#stderrColor = stderrColor
- this.#stderrChalk = stderrColor ? new Chalk({ level }) : this.#noColorChalk
- this.#logColors = COLOR_PALETTE({ chalk: this.#stderrChalk })
- this.#command = command
- this.#levelIndex = LEVEL_OPTIONS[loglevel].index
- this.#timing = timing
- this.#json = json
- this.#heading = heading
- this.#silent = this.#levelIndex <= 0
- // Emit resume event on the logs which will flush output
- log.resume()
- output.flush()
- this.#progress.load({
- unicode,
- enabled: !!progress && !this.#silent,
- })
- }
- // STREAM WRITES
- // Write formatted and (non-)colorized output to streams
- #write (stream, options, ...args) {
- const colors = stream === this.#stdout ? this.#stdoutColor : this.#stderrColor
- const value = formatWithOptions({ colors, ...options }, ...args)
- this.#progress.write(() => stream.write(value))
- }
- // HANDLERS
- // Arrow function assigned to a private class field so it can be passed directly as a listener and still reference "this"
- #logHandler = withMeta((level, meta, ...args) => {
- switch (level) {
- case log.KEYS.resume:
- this.#logState.buffering = false
- this.#logState.buffer.forEach((item) => this.#tryWriteLog(...item))
- this.#logState.buffer.length = 0
- break
- case log.KEYS.pause:
- this.#logState.buffering = true
- break
- default:
- if (this.#logState.buffering) {
- this.#logState.buffer.push([level, meta, ...args])
- } else {
- this.#tryWriteLog(level, meta, ...args)
- }
- break
- }
- })
- // Arrow function assigned to a private class field so it can be passed directly as a listener and still reference "this"
- #outputHandler = withMeta((level, meta, ...args) => {
- this.#json = typeof meta.json === 'boolean' ? meta.json : this.#json
- switch (level) {
- case output.KEYS.flush: {
- this.#outputState.buffering = false
- if (this.#json) {
- const json = getJsonBuffer(meta, this.#outputState.buffer)
- if (json) {
- this.#writeOutput(output.KEYS.standard, meta, JSON.stringify(json, null, 2))
- }
- } else {
- this.#outputState.buffer.forEach((item) => this.#writeOutput(...item))
- }
- this.#outputState.buffer.length = 0
- break
- }
- case output.KEYS.buffer:
- this.#outputState.buffer.push([output.KEYS.standard, meta, ...args])
- break
- default:
- if (this.#outputState.buffering) {
- this.#outputState.buffer.push([level, meta, ...args])
- } else {
- // XXX: Check if the argument looks like a run-script banner. This should be replaced with proc-log.META in @npmcli/run-script
- if (typeof args[0] === 'string' && args[0].startsWith('\n> ') && args[0].endsWith('\n')) {
- if (this.#silent || ['exec', 'explore'].includes(this.#command)) {
- // Silent mode and some specific commands always hide run script banners
- break
- } else if (this.#json) {
- // 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.
- // 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".
- level = output.KEYS.error
- }
- }
- this.#writeOutput(level, meta, ...args)
- }
- break
- }
- })
- #inputHandler = withMeta((level, meta, ...args) => {
- switch (level) {
- case input.KEYS.start:
- log.pause()
- this.#outputState.buffering = true
- this.#progress.off()
- break
- case input.KEYS.end: {
- log.resume()
- // For silent prompts (like password), add newline to preserve output
- if (meta?.silent) {
- output.standard()
- }
- output.flush()
- this.#progress.resume()
- break
- }
- case input.KEYS.read: {
- // 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.
- const [res, rej, p] = args
- // Use sequential input management to avoid race condition which causes issues with spinner and adding newlines.
- input.start()
- return p()
- .then((result) => {
- // If user hits enter, process end event and return input.
- input.end({ [META]: true, silent: meta?.silent })
- res(result)
- return result
- })
- .catch((error) => {
- // If user hits ctrl+c, add newline to preserve output.
- output.standard()
- input.end()
- rej(error)
- })
- }
- }
- })
- // OUTPUT
- #writeOutput (level, meta, ...args) {
- switch (level) {
- case output.KEYS.standard:
- this.#write(this.#stdout, meta, ...args)
- break
- case output.KEYS.error:
- this.#write(this.#stderr, meta, ...args)
- break
- }
- }
- // LOGS
- #tryWriteLog (level, meta, ...args) {
- try {
- // 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.
- // TODO: this could probably be moved to arborist now that display is refactored
- const [heading, message, expl] = args
- if (level === log.KEYS.warn && heading === 'ERESOLVE' && expl && typeof expl === 'object') {
- this.#writeLog(level, meta, heading, message)
- this.#writeLog(level, meta, '', explain(expl, this.#stderrChalk, 2))
- return
- }
- this.#writeLog(level, meta, ...args)
- } catch (ex) {
- try {
- // if it crashed once, it might again!
- this.#writeLog(log.KEYS.verbose, meta, '', `attempt to log crashed`, ...args, ex)
- } catch (ex2) {
- // 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.
- // eslint-disable-next-line no-console
- console.error(`attempt to log crashed`, ex, ex2)
- }
- }
- }
- #writeLog (level, meta, ...args) {
- const levelOpts = LEVEL_METHODS[level]
- const show = levelOpts.show ?? (({ index }) => levelOpts.index <= index)
- const force = meta.force && !this.#silent
- if (force || show({ index: this.#levelIndex, timing: this.#timing })) {
- // this mutates the array so we can pass args directly to format later
- const title = args.shift()
- const prefix = [
- this.#logColors.heading(this.#heading),
- this.#logColors[level](level),
- title ? this.#logColors.title(title) : null,
- ]
- const writeOpts = { prefix }
- // notice logs typically come from `npm-notice` headers in responses. Some of them have 2fa login links so we skip redaction.
- if (level === 'notice') {
- writeOpts.redact = false
- // Deduplicate notices within a single command execution, unless in verbose mode
- if (this.#levelIndex < LEVEL_OPTIONS.verbose.index) {
- const noticeKey = JSON.stringify([title, ...args])
- if (this.#seenNotices.has(noticeKey)) {
- return
- }
- this.#seenNotices.add(noticeKey)
- }
- }
- this.#write(this.#stderr, writeOpts, ...args)
- }
- }
- }
- class Progress {
- // Taken from https://github.com/sindresorhus/cli-spinners
- // MIT License
- // Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
- static dots = { duration: 80, frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] }
- static lines = { duration: 130, frames: ['-', '\\', '|', '/'] }
- #enabled = false
- #frameIndex = 0
- #interval
- #lastUpdate = 0
- #spinner
- #stream
- // Initial timeout to wait to start rendering
- #timeout
- #rendered = false
- // We are rendering if enabled option is set and we are not waiting for the render timeout
- get #rendering () {
- return this.#enabled && !this.#timeout
- }
- // We are spinning if enabled option is set and the render interval has been set
- get #spinning () {
- return this.#enabled && this.#interval
- }
- constructor ({ stream }) {
- this.#stream = stream
- }
- load ({ enabled, unicode }) {
- this.#enabled = enabled
- this.#spinner = unicode ? Progress.dots : Progress.lines
- // Wait 200 ms so we don't render the spinner for short durations
- this.#timeout = setTimeout(() => {
- this.#timeout = null
- this.#render()
- }, 200)
- // Make sure this timeout does not keep the process open
- this.#timeout.unref()
- }
- off () {
- if (!this.#enabled) {
- return
- }
- clearTimeout(this.#timeout)
- this.#timeout = null
- clearInterval(this.#interval)
- this.#interval = null
- this.#frameIndex = 0
- this.#lastUpdate = 0
- this.#clearSpinner()
- }
- resume () {
- this.#render(true)
- }
- // If we are currently rendering the spinner we clear it before writing our line and then re-render the spinner after.
- // If not then all we need to do is write the line.
- write (write) {
- if (this.#spinning) {
- this.#clearSpinner()
- }
- write()
- if (this.#spinning) {
- this.#render()
- }
- }
- #render (resuming) {
- if (!this.#rendering) {
- return
- }
- // 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
- this.#renderFrame(Date.now() - this.#lastUpdate >= this.#spinner.duration, resuming)
- if (!this.#interval) {
- this.#interval = setInterval(() => this.#renderFrame(true), this.#spinner.duration)
- // Make sure this timeout does not keep the process open
- this.#interval.unref()
- }
- this.#interval.refresh()
- }
- #renderFrame (next, resuming) {
- if (next) {
- this.#lastUpdate = Date.now()
- this.#frameIndex++
- if (this.#frameIndex >= this.#spinner.frames.length) {
- this.#frameIndex = 0
- }
- }
- if (!resuming) {
- this.#clearSpinner()
- }
- this.#stream.write(this.#spinner.frames[this.#frameIndex])
- this.#rendered = true
- }
- #clearSpinner () {
- if (!this.#rendered) {
- return
- }
- // Move to the start of the line and clear the rest of the line
- this.#stream.cursorTo(0)
- this.#stream.clearLine(1)
- this.#rendered = false
- }
- }
- module.exports = Display
|