Mini Shell
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
}
// These are important
// 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
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()
}
get chalk () {
return {
noColor: this.#noColorChalk,
stdout: this.#stdoutChalk,
stderr: this.#stderrChalk,
}
}
async load ({
command,
heading,
json,
loglevel,
progress,
stderrColor,
stdoutColor,
timing,
unicode,
}) {
// get createSupportsColor from chalk directly if this lands
// https://github.com/chalk/chalk/pull/600
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 {
// HACK: Check if the argument looks like a run-script banner. This can 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 dont 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()
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
return input.start(() => p()
.then(res)
.catch(rej)
// Any call to procLog.input.read will render a prompt to the user, so we always
// add a single newline of output to stdout to move the cursor to the next line
.finally(() => output.standard('')))
}
}
})
// OUTPUT
#writeOutput (level, meta, ...args) {
switch (level) {
case output.KEYS.standard:
this.#write(this.#stdout, {}, ...args)
break
case output.KEYS.error:
this.#write(this.#stderr, {}, ...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,
]
this.#write(this.#stderr, { prefix }, ...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: ['-', '\\', '|', '/'] }
#stream
#spinner
#enabled = false
#frameIndex = 0
#lastUpdate = 0
#interval
#timeout
// We are rendering is 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
// Dont render the spinner for short durations
this.#render(200)
}
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()
}
// If we are currenting 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 (ms) {
if (ms) {
this.#timeout = setTimeout(() => {
this.#timeout = null
this.#renderSpinner()
}, ms)
// Make sure this timeout does not keep the process open
this.#timeout.unref()
} else {
this.#renderSpinner()
}
}
#renderSpinner () {
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)
clearInterval(this.#interval)
this.#interval = setInterval(() => this.#renderFrame(true), this.#spinner.duration)
}
#renderFrame (next) {
if (next) {
this.#lastUpdate = Date.now()
this.#frameIndex++
if (this.#frameIndex >= this.#spinner.frames.length) {
this.#frameIndex = 0
}
}
this.#clearSpinner()
this.#stream.write(this.#spinner.frames[this.#frameIndex])
}
#clearSpinner () {
// Move to the start of the line and clear the rest of the line
this.#stream.cursorTo(0)
this.#stream.clearLine(1)
}
}
module.exports = Display
Zerion Mini Shell 1.0