display.js 16 KB

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