From bb1d457b2944501a551ca493d0d160f4f91f2d43 Mon Sep 17 00:00:00 2001 From: Luke Karrys Date: Sat, 27 Apr 2024 13:55:13 -0700 Subject: [PATCH] feat: add spinner Closes #7425 --- lib/commands/init.js | 8 +- lib/utils/display.js | 164 +++++++++++++++--- lib/utils/format.js | 5 + lib/utils/read-user-info.js | 12 +- .../lib/utils/open-url-prompt.js.test.cjs | 1 + test/lib/utils/read-user-info.js | 50 +++--- workspaces/libnpmexec/lib/index.js | 25 ++- 7 files changed, 195 insertions(+), 70 deletions(-) diff --git a/lib/commands/init.js b/lib/commands/init.js index 1847e19a9560f..b4d95469ae4ae 100644 --- a/lib/commands/init.js +++ b/lib/commands/init.js @@ -6,7 +6,7 @@ const npa = require('npm-package-arg') const libexec = require('libnpmexec') const mapWorkspaces = require('@npmcli/map-workspaces') const PackageJson = require('@npmcli/package-json') -const { log, output } = require('proc-log') +const { log, output, input } = require('proc-log') const updateWorkspaces = require('../utils/update-workspaces.js') const BaseCommand = require('../base-cmd.js') @@ -148,8 +148,6 @@ class Init extends BaseCommand { } async template (path = process.cwd()) { - log.pause() - const initFile = this.npm.config.get('init-module') if (!this.npm.config.get('yes') && !this.npm.config.get('force')) { output.standard([ @@ -167,7 +165,7 @@ class Init extends BaseCommand { } try { - const data = await initJson(path, initFile, this.npm.config) + const data = await input.start(() => initJson(path, initFile, this.npm.config)) log.silly('package data', data) return data } catch (er) { @@ -176,8 +174,6 @@ class Init extends BaseCommand { } else { throw er } - } finally { - log.resume() } } diff --git a/lib/utils/display.js b/lib/utils/display.js index 299edc797aaf3..682ca62963626 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -1,5 +1,5 @@ const proggy = require('proggy') -const { log, output, META } = require('proc-log') +const { log, output, input, META } = require('proc-log') const { explain } = require('./explain-eresolve.js') const { formatWithOptions } = require('./format') @@ -137,6 +137,7 @@ class Display { // 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) } off () { @@ -146,8 +147,9 @@ class Display { process.off('output', this.#outputHandler) this.#outputState.buffer.length = 0 + process.off('input', this.#inputHandler) if (this.#progress) { - this.#progress.stop() + this.#progress.off() } } @@ -207,12 +209,14 @@ class Display { // STREAM WRITES // Write formatted and (non-)colorized output to streams - #stdoutWrite (options, ...args) { - this.#stdout.write(formatWithOptions({ colors: this.#stdoutColor, ...options }, ...args)) - } - - #stderrWrite (options, ...args) { - this.#stderr.write(formatWithOptions({ colors: this.#stderrColor, ...options }, ...args)) + #write (stream, options, ...args) { + if (this.#progress) { + this.#progress.clear() + } + stream.write(formatWithOptions({ + colors: stream === this.#stdout ? this.#stdoutColor : this.#stderrColor, + ...options, + }, ...args)) } // HANDLERS @@ -259,6 +263,9 @@ class Display { ) } else { this.#outputState.buffer.forEach((item) => this.#writeOutput(...item)) + if (args.length) { + this.#outputState.buffer.push([output.KEYS.standard, meta, ...args]) + } } this.#outputState.buffer.length = 0 @@ -277,10 +284,8 @@ class Display { // HACK: if it looks like the banner and we are in a state where we hide the // banner then dont write any output. This hack can be replaced with proc-log.META - const isBanner = args.length === 1 && - typeof args[0] === 'string' && - args[0].startsWith('\n> ') && - args[0].endsWith('\n') + const arg = args[0] + const isBanner = typeof arg === 'string' && arg.startsWith('\n> ') && arg.endsWith('\n') const hideBanner = this.#silent || ['exec', 'explore'].includes(this.#command) if (isBanner && hideBanner) { return @@ -289,16 +294,31 @@ class Display { this.#writeOutput(level, meta, ...args) }) + #inputHandler = withMeta((level, meta, ...args) => { + if (level === input.KEYS.start) { + log.pause() + this.#outputState.buffering = true + this.#progress.pause() + return + } + + if (level === input.KEYS.end) { + log.resume() + output.flush() + this.#progress.resume() + } + }) + // OUTPUT #writeOutput (level, meta, ...args) { if (level === output.KEYS.standard) { - this.#stdoutWrite({}, ...args) + this.#write(this.#stdout, {}, ...args) return } if (level === output.KEYS.error) { - this.#stderrWrite({}, ...args) + this.#write(this.#stderr, {}, ...args) } } @@ -344,22 +364,122 @@ class Display { this.#logColors[level](level), title ? this.#logColors.title(title) : null, ] - this.#stderrWrite({ prefix }, ...args) - } else if (this.#progress) { - // TODO: make this display a single log line of filtered messages + this.#write(this.#stderr, { prefix }, ...args) } } // PROGRESS #startProgress ({ progress, unicode }) { - if (!progress || this.#silent) { + this.#progress = new Progress({ + enabled: !!progress && !this.#silent, + unicode, + stream: this.#stderr, + }) + } +} + +class Progress { + // Taken from https://github.com/sindresorhus/cli-spinners + // MIT License + // Copyright (c) Sindre Sorhus (https://sindresorhus.com) + static dots = { duration: 80, frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] } + static lines = { duration: 130, frames: ['-', '\\', '|', '/'] } + + #stream + #spinner + #client + + #frame = 0 + #running = false + #timeout + #interval + + constructor ({ enabled, stream, unicode }) { + if (!enabled) { + return + } + + this.#client = proggy.createClient({ normalize: true }) + this.#stream = stream + this.#spinner = unicode ? Progress.dots : Progress.lines + this.#render({ delay: 500 }) + } + + off () { + if (!this.#stream) { + return + } + this.#clear() + } + + pause () { + if (!this.#stream) { + return + } + this.#clear({ clearLine: true }) + } + + resume () { + if (!this.#stream) { + return + } + this.#render() + } + + persist (stream, value) { + if (this.#stream) { + this.#stream.cursorTo(0) + } + stream.write(value) + if (this.#stream) { + this.#render({ delay: 10 }) + } + } + + clear () { + if (!this.#stream) { return } - this.#progress = proggy.createClient({ normalize: true }) - // TODO: implement proggy trackers in arborist/doctor - // TODO: listen to progress events here and build progress UI - // TODO: see deprecated gauge package for what unicode chars were used + this.#stream.cursorTo(0) + } + + #clear ({ clearLine } = {}) { + this.#running = false + this.#stream.cursorTo(0) + if (clearLine) { + this.#stream.clearLine(1) + } + clearTimeout(this.#timeout) + clearInterval(this.#interval) + } + + #render ({ delay } = {}) { + if (delay) { + this.#stream.cursorTo(0) + this.#stream.clearLine(1) + clearTimeout(this.#timeout) + this.#timeout = setTimeout(() => this.#render(), delay) + return + } + this.#running = true + this.#renderFrame() + clearInterval(this.#interval) + this.#interval = setInterval(() => this.#renderFrame(), this.#spinner.duration) + } + + #renderFrame () { + if (this.#running) { + this.#stream.cursorTo(0) + this.#stream.write(this.#spinner.frames[this.#nextFrame()]) + } + } + + #nextFrame () { + if (this.#frame >= this.#spinner.frames.length) { + this.#frame = 0 + } + return this.#frame++ } } diff --git a/lib/utils/format.js b/lib/utils/format.js index abfbf9e331704..c0578576318d2 100644 --- a/lib/utils/format.js +++ b/lib/utils/format.js @@ -40,6 +40,11 @@ function STRIP_C01 (str) { const formatWithOptions = ({ prefix: prefixes = [], eol = '\n', ...options }, ...args) => { const prefix = prefixes.filter(p => p != null).join(' ') + // We output an empty string in some places which means newline. This made sense when we + // wrote via console.log as that would always output a newline. + if (args.length === 1 && args[0] === '') { + return eol + } const formatted = STRIP_C01(baseFormatWithOptions(options, ...args)) // Splitting could be changed to only `\n` once we are sure we only emit unix newlines. // The eol param to this function will put the correct newlines in place for the returned string. diff --git a/lib/utils/read-user-info.js b/lib/utils/read-user-info.js index b2cd7374c17c3..dcd19bb6d14c1 100644 --- a/lib/utils/read-user-info.js +++ b/lib/utils/read-user-info.js @@ -1,6 +1,6 @@ const { read } = require('read') const userValidate = require('npm-user-validate') -const { log } = require('proc-log') +const { log, input, output } = require('proc-log') exports.otp = readOTP exports.password = readPassword @@ -16,12 +16,14 @@ const passwordPrompt = 'npm password: ' const usernamePrompt = 'npm username: ' const emailPrompt = 'email (this IS public): ' +const procLogRead = (...args) => input.start(() => read(...args).finally(() => output.standard(''))) + function readOTP (msg = otpPrompt, otp, isRetry) { if (isRetry && otp && /^[\d ]+$|^[A-Fa-f0-9]{64,64}$/.test(otp)) { return otp.replace(/\s+/g, '') } - return read({ prompt: msg, default: otp || '' }) + return procLogRead({ prompt: msg, default: otp || '' }) .then((rOtp) => readOTP(msg, rOtp, true)) } @@ -30,7 +32,7 @@ function readPassword (msg = passwordPrompt, password, isRetry) { return password } - return read({ prompt: msg, silent: true, default: password || '' }) + return procLogRead({ prompt: msg, silent: true, default: password || '' }) .then((rPassword) => readPassword(msg, rPassword, true)) } @@ -44,7 +46,7 @@ function readUsername (msg = usernamePrompt, username, isRetry) { } } - return read({ prompt: msg, default: username || '' }) + return procLogRead({ prompt: msg, default: username || '' }) .then((rUsername) => readUsername(msg, rUsername, true)) } @@ -58,6 +60,6 @@ function readEmail (msg = emailPrompt, email, isRetry) { } } - return read({ prompt: msg, default: email || '' }) + return procLogRead({ prompt: msg, default: email || '' }) .then((username) => readEmail(msg, username, true)) } diff --git a/tap-snapshots/test/lib/utils/open-url-prompt.js.test.cjs b/tap-snapshots/test/lib/utils/open-url-prompt.js.test.cjs index cf5feed44cc37..a0af353917772 100644 --- a/tap-snapshots/test/lib/utils/open-url-prompt.js.test.cjs +++ b/tap-snapshots/test/lib/utils/open-url-prompt.js.test.cjs @@ -8,6 +8,7 @@ exports[`test/lib/utils/open-url-prompt.js TAP does not error when opener can not find command > Outputs extra Browser unavailable message and url 1`] = ` npm home: https://www.npmjs.com + Browser unavailable. Please open the URL manually: https://www.npmjs.com ` diff --git a/test/lib/utils/read-user-info.js b/test/lib/utils/read-user-info.js index 854277783bb6b..91f798d2bef5d 100644 --- a/test/lib/utils/read-user-info.js +++ b/test/lib/utils/read-user-info.js @@ -1,39 +1,41 @@ const t = require('tap') +const procLog = require('proc-log') const tmock = require('../../fixtures/tmock') let readOpts = null let readResult = null -const read = { read: async (opts) => { - readOpts = opts - return readResult -} } - -const npmUserValidate = { - username: (username) => { - if (username === 'invalid') { - return new Error('invalid username') - } - - return null - }, - email: (email) => { - if (email.startsWith('invalid')) { - return new Error('invalid email') - } - - return null - }, -} - let logMsg = null + const readUserInfo = tmock(t, '{LIB}/utils/read-user-info.js', { - read, + read: { + read: async (opts) => { + readOpts = opts + return readResult + }, + }, 'proc-log': { + ...procLog, log: { + ...procLog.log, warn: (msg) => logMsg = msg, }, }, - 'npm-user-validate': npmUserValidate, + 'npm-user-validate': { + username: (username) => { + if (username === 'invalid') { + return new Error('invalid username') + } + + return null + }, + email: (email) => { + if (email.startsWith('invalid')) { + return new Error('invalid email') + } + + return null + }, + }, }) t.beforeEach(() => { diff --git a/workspaces/libnpmexec/lib/index.js b/workspaces/libnpmexec/lib/index.js index 944f34b01c237..6298b5ff09bfb 100644 --- a/workspaces/libnpmexec/lib/index.js +++ b/workspaces/libnpmexec/lib/index.js @@ -4,7 +4,7 @@ const { mkdir } = require('fs/promises') const Arborist = require('@npmcli/arborist') const ciInfo = require('ci-info') const crypto = require('crypto') -const { log } = require('proc-log') +const { log, input } = require('proc-log') const npa = require('npm-package-arg') const pacote = require('pacote') const { read } = require('read') @@ -242,26 +242,25 @@ const exec = async (opts) => { if (add.length) { if (!yes) { - const missingPackages = add.map(a => `${a.replace(/@$/, '')}`) + const addList = add.map(a => `${a.replace(/@$/, '')}`) + // set -n to always say no if (yes === false) { // Error message lists missing package(s) when process is canceled /* eslint-disable-next-line max-len */ - throw new Error(`npx canceled due to missing packages and no YES option: ${JSON.stringify(missingPackages)}`) + throw new Error(`npx canceled due to missing packages and no YES option: ${JSON.stringify(addList)}`) } if (noTTY() || ciInfo.isCI) { - log.warn('exec', `The following package${ - add.length === 1 ? ' was' : 's were' - } not found and will be installed: ${ - add.map((pkg) => pkg.replace(/@$/, '')).join(', ') - }`) + log.warn('exec', + /* eslint-disable-next-line max-len */ + `The following package${add.length === 1 ? ' was' : 's were'} not found and will be installed:`, + addList.join(', ') + ) } else { - const addList = missingPackages.join('\n') + '\n' - const prompt = `Need to install the following packages:\n${ - addList - }Ok to proceed? ` - const confirm = await read({ prompt, default: 'y' }) + /* eslint-disable-next-line max-len */ + const prompt = `Need to install the following packages:\n${addList.join('\n')}\nOk to proceed? ` + const confirm = await input.start(() => read({ prompt, default: 'y' })) if (confirm.trim().toLowerCase().charAt(0) !== 'y') { throw new Error('canceled') }