From 030d2165316d600bea6888c98712db633b24224d Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Sat, 24 May 2025 05:15:52 +0200 Subject: [PATCH 01/13] fix: prevent spinner from appearing between consecutive prompts --- lib/utils/auth.js | 2 +- lib/utils/display.js | 7 ++++++- lib/utils/read-user-info.js | 3 +++ tap-snapshots/test/lib/commands/init.js.test.cjs | 1 - test/lib/commands/token.js | 10 ++++------ 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/utils/auth.js b/lib/utils/auth.js index 747271169124b..67e5105221c24 100644 --- a/lib/utils/auth.js +++ b/lib/utils/auth.js @@ -51,7 +51,7 @@ const adduser = async (npm, { creds, ...opts }) => { if (!res) { const username = await read.username('Username:', creds.username) const password = await read.password('Password:', creds.password) - const email = await read.email('Email: (this IS public) ', creds.email) + const email = await read.email('Email (this IS public):', creds.email) // npm registry quirk: If you "add" an existing user with their current // password, it's effectively a login, and if that account has otp you'll // be prompted for it. diff --git a/lib/utils/display.js b/lib/utils/display.js index 67a3b98c0417a..b7b2b8383ed0d 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -169,6 +169,7 @@ class Display { // progress #progress + #promptCount = 0 // options #command @@ -340,15 +341,19 @@ class Display { #inputHandler = withMeta((level, meta, ...args) => { switch (level) { case input.KEYS.start: + this.#promptCount++ log.pause() this.#outputState.buffering = true this.#progress.off() break case input.KEYS.end: + this.#promptCount-- log.resume() output.flush() - this.#progress.resume() + if (this.#promptCount === 0) { + this.#progress.resume() + } break case input.KEYS.read: { diff --git a/lib/utils/read-user-info.js b/lib/utils/read-user-info.js index a9a50f8263ff6..10ad63d799c23 100644 --- a/lib/utils/read-user-info.js +++ b/lib/utils/read-user-info.js @@ -24,6 +24,9 @@ function readOTP (msg = otpPrompt, otp, isRetry) { function readPassword (msg = passwordPrompt, password, isRetry) { if (isRetry && password) { + // because the prompt is silent and we need to move the cursor + // to the next line so the next output is on a new line. + process.stdout.write('\n') return password } diff --git a/tap-snapshots/test/lib/commands/init.js.test.cjs b/tap-snapshots/test/lib/commands/init.js.test.cjs index eae04d77d2e82..821193a55e1a9 100644 --- a/tap-snapshots/test/lib/commands/init.js.test.cjs +++ b/tap-snapshots/test/lib/commands/init.js.test.cjs @@ -20,6 +20,5 @@ Press ^C at any time to quit. exports[`test/lib/commands/init.js TAP workspaces no args -- yes > should print helper info 1`] = ` - added 1 package in {TIME} ` diff --git a/test/lib/commands/token.js b/test/lib/commands/token.js index f60a938b5b34b..1290a5ee9cb17 100644 --- a/test/lib/commands/token.js +++ b/test/lib/commands/token.js @@ -265,7 +265,6 @@ t.test('token create', async t => { registry.createToken({ password, cidr }) await npm.exec('token', ['create']) t.strictSame(outputs, [ - '', 'Created publish token n3wt0k3n', 'with IP whitelist: 10.0.0.0/8,192.168.1.0/24', ]) @@ -292,7 +291,6 @@ t.test('token create read only', async t => { registry.createToken({ readonly: true, password }) await npm.exec('token', ['create']) t.strictSame(outputs, [ - '', 'Created read only token n3wt0k3n', ]) }) @@ -349,10 +347,10 @@ t.test('token create parseable output', async t => { }, { replace: true }) registry.createToken({ password, cidr }) await npm.exec('token', ['create']) - t.equal(outputs[1], 'token\tn3wt0k3n') - t.ok(outputs[2].startsWith('created\t')) - t.equal(outputs[3], 'readonly\tfalse') - t.equal(outputs[4], 'cidr_whitelist\t10.0.0.0/8,192.168.1.0/24') + t.equal(outputs[0], 'token\tn3wt0k3n') + t.ok(outputs[1].startsWith('created\t')) + t.equal(outputs[2], 'readonly\tfalse') + t.equal(outputs[3], 'cidr_whitelist\t10.0.0.0/8,192.168.1.0/24') }) t.test('token create ipv6 cidr', async t => { From ef7f0ae8b53e89eee60a23c9981635d687c1a028 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Sat, 24 May 2025 17:28:57 +0200 Subject: [PATCH 02/13] Update prompt, stop cursor blinking --- lib/utils/auth.js | 2 +- lib/utils/display.js | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/utils/auth.js b/lib/utils/auth.js index 67e5105221c24..a617ab9430b2a 100644 --- a/lib/utils/auth.js +++ b/lib/utils/auth.js @@ -51,7 +51,7 @@ const adduser = async (npm, { creds, ...opts }) => { if (!res) { const username = await read.username('Username:', creds.username) const password = await read.password('Password:', creds.password) - const email = await read.email('Email (this IS public):', creds.email) + const email = await read.email('Email (this will be public):', creds.email) // npm registry quirk: If you "add" an existing user with their current // password, it's effectively a login, and if that account has otp you'll // be prompted for it. diff --git a/lib/utils/display.js b/lib/utils/display.js index b7b2b8383ed0d..502c3c85b738b 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -362,10 +362,7 @@ class Display { 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(''))) + .catch(rej)) } } }) @@ -479,6 +476,8 @@ class Progress { this.#frameIndex = 0 this.#lastUpdate = 0 this.#clearSpinner() + // Show the cursor again when spinner stops + this.#stream.write('\x1B[?25h') } resume () { @@ -515,6 +514,8 @@ class Progress { if (!this.#rendering) { return } + // Hide the cursor when spinner is active + this.#stream.write('\x1B[?25l') // 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) From 676a21a52330912ac01d6630cdc731d8e5969ea9 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Sat, 24 May 2025 17:56:36 +0200 Subject: [PATCH 03/13] Add exithandler --- lib/utils/display.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/utils/display.js b/lib/utils/display.js index 502c3c85b738b..e7e0cdcc57bcc 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -456,6 +456,10 @@ class Progress { constructor ({ stream }) { this.#stream = stream + // Add exit/signal handlers to always restore the cursor + process.on('SIGINT', this.#exitHandler) + process.on('SIGTERM', this.#exitHandler) + process.on('exit', this.#exitHandler) } load ({ enabled, unicode }) { @@ -478,6 +482,10 @@ class Progress { this.#clearSpinner() // Show the cursor again when spinner stops this.#stream.write('\x1B[?25h') + // Remove exit/signal handlers + process.off('SIGINT', this.#exitHandler) + process.off('SIGTERM', this.#exitHandler) + process.off('exit', this.#exitHandler) } resume () { @@ -540,6 +548,16 @@ class Progress { this.#stream.cursorTo(0) this.#stream.clearLine(1) } + + #exitHandler = () => { + // Always show the cursor on exit + try { + this.#stream.write('\x1B[?25h') + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to restore cursor visibility:', e) + } + } } module.exports = Display From 8a44dfa712eed34801b430af536bea434cb84039 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Sat, 24 May 2025 18:10:31 +0200 Subject: [PATCH 04/13] Fix display test --- test/lib/utils/display.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/utils/display.js b/test/lib/utils/display.js index 78bffa0221d03..688cfaec8a13c 100644 --- a/test/lib/utils/display.js +++ b/test/lib/utils/display.js @@ -94,7 +94,7 @@ t.test('can do progress', async (t) => { log.error('', 'after input') output.standard('after input') - t.strictSame([...new Set(outputErrors)].sort(), ['-', '/', '\\', '|']) + t.strictSame([...new Set(outputErrors)].sort(), ['\u001b[?25h', '\u001b[?25l', '-', '/', '\\', '|']) t.strictSame(logs, ['error before input', 'error during input', 'error after input']) t.strictSame(outputs, ['before input', 'during input', 'after input']) }) From d31142666d6cca7a3373365fed186e056dc0aa3e Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Sat, 24 May 2025 19:48:51 +0200 Subject: [PATCH 05/13] Add cursor hidden flag and global exithandler --- lib/utils/display.js | 58 ++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/lib/utils/display.js b/lib/utils/display.js index e7e0cdcc57bcc..c9f4344525949 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -443,6 +443,24 @@ class Progress { #lastUpdate = 0 #interval #timeout + #cursorHidden = false + + // Static flag to ensure listeners are only added once + static #listenersAdded = false + static #anyCursorHidden = false + + static #globalExitHandler = () => { + // Show the cursor if any Progress instance hid it + if (Progress.#anyCursorHidden) { + try { + process.stderr.write('\x1B[?25h') + Progress.#anyCursorHidden = false + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to restore cursor visibility:', e) + } + } + } // We are rendering is enabled option is set and we are not waiting for the render timeout get #rendering () { @@ -456,10 +474,13 @@ class Progress { constructor ({ stream }) { this.#stream = stream - // Add exit/signal handlers to always restore the cursor - process.on('SIGINT', this.#exitHandler) - process.on('SIGTERM', this.#exitHandler) - process.on('exit', this.#exitHandler) + // Add exit/signal handlers only once globally + if (!Progress.#listenersAdded) { + process.on('SIGINT', Progress.#globalExitHandler) + process.on('SIGTERM', Progress.#globalExitHandler) + process.on('exit', Progress.#globalExitHandler) + Progress.#listenersAdded = true + } } load ({ enabled, unicode }) { @@ -480,12 +501,13 @@ class Progress { this.#frameIndex = 0 this.#lastUpdate = 0 this.#clearSpinner() - // Show the cursor again when spinner stops - this.#stream.write('\x1B[?25h') - // Remove exit/signal handlers - process.off('SIGINT', this.#exitHandler) - process.off('SIGTERM', this.#exitHandler) - process.off('exit', this.#exitHandler) + // Show the cursor again when spinner stops, but only if we hid it + if (this.#cursorHidden) { + this.#stream.write('\x1B[?25h') + this.#cursorHidden = false + Progress.#anyCursorHidden = false + } + // Do not remove global listeners here; they are shared } resume () { @@ -523,7 +545,11 @@ class Progress { return } // Hide the cursor when spinner is active - this.#stream.write('\x1B[?25l') + if (!this.#cursorHidden) { + this.#stream.write('\x1B[?25l') + this.#cursorHidden = true + Progress.#anyCursorHidden = true + } // 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) @@ -548,16 +574,6 @@ class Progress { this.#stream.cursorTo(0) this.#stream.clearLine(1) } - - #exitHandler = () => { - // Always show the cursor on exit - try { - this.#stream.write('\x1B[?25h') - } catch (e) { - // eslint-disable-next-line no-console - console.error('Failed to restore cursor visibility:', e) - } - } } module.exports = Display From 1110958aedf46e5778047929498defc149c0d536 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Sun, 25 May 2025 14:48:05 +0200 Subject: [PATCH 06/13] Move cursor blink logic to separate PR --- lib/utils/display.js | 38 -------------------------------------- test/lib/utils/display.js | 2 +- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/lib/utils/display.js b/lib/utils/display.js index c9f4344525949..d18b1e6ef5107 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -443,24 +443,6 @@ class Progress { #lastUpdate = 0 #interval #timeout - #cursorHidden = false - - // Static flag to ensure listeners are only added once - static #listenersAdded = false - static #anyCursorHidden = false - - static #globalExitHandler = () => { - // Show the cursor if any Progress instance hid it - if (Progress.#anyCursorHidden) { - try { - process.stderr.write('\x1B[?25h') - Progress.#anyCursorHidden = false - } catch (e) { - // eslint-disable-next-line no-console - console.error('Failed to restore cursor visibility:', e) - } - } - } // We are rendering is enabled option is set and we are not waiting for the render timeout get #rendering () { @@ -474,13 +456,6 @@ class Progress { constructor ({ stream }) { this.#stream = stream - // Add exit/signal handlers only once globally - if (!Progress.#listenersAdded) { - process.on('SIGINT', Progress.#globalExitHandler) - process.on('SIGTERM', Progress.#globalExitHandler) - process.on('exit', Progress.#globalExitHandler) - Progress.#listenersAdded = true - } } load ({ enabled, unicode }) { @@ -501,13 +476,6 @@ class Progress { this.#frameIndex = 0 this.#lastUpdate = 0 this.#clearSpinner() - // Show the cursor again when spinner stops, but only if we hid it - if (this.#cursorHidden) { - this.#stream.write('\x1B[?25h') - this.#cursorHidden = false - Progress.#anyCursorHidden = false - } - // Do not remove global listeners here; they are shared } resume () { @@ -544,12 +512,6 @@ class Progress { if (!this.#rendering) { return } - // Hide the cursor when spinner is active - if (!this.#cursorHidden) { - this.#stream.write('\x1B[?25l') - this.#cursorHidden = true - Progress.#anyCursorHidden = true - } // 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) diff --git a/test/lib/utils/display.js b/test/lib/utils/display.js index 688cfaec8a13c..78bffa0221d03 100644 --- a/test/lib/utils/display.js +++ b/test/lib/utils/display.js @@ -94,7 +94,7 @@ t.test('can do progress', async (t) => { log.error('', 'after input') output.standard('after input') - t.strictSame([...new Set(outputErrors)].sort(), ['\u001b[?25h', '\u001b[?25l', '-', '/', '\\', '|']) + t.strictSame([...new Set(outputErrors)].sort(), ['-', '/', '\\', '|']) t.strictSame(logs, ['error before input', 'error during input', 'error after input']) t.strictSame(outputs, ['before input', 'during input', 'after input']) }) From 2cfa70c879a4083c3d3bc803e32d5e506f6bd035 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Sun, 25 May 2025 19:23:45 +0200 Subject: [PATCH 07/13] Adjust snapshot --- tap-snapshots/test/lib/utils/open-url.js.test.cjs | 1 - 1 file changed, 1 deletion(-) diff --git a/tap-snapshots/test/lib/utils/open-url.js.test.cjs b/tap-snapshots/test/lib/utils/open-url.js.test.cjs index fa256ba131447..6ef050d66dfd7 100644 --- a/tap-snapshots/test/lib/utils/open-url.js.test.cjs +++ b/tap-snapshots/test/lib/utils/open-url.js.test.cjs @@ -25,7 +25,6 @@ https://www.npmjs.com exports[`test/lib/utils/open-url.js TAP open url prompt 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 ` From 702b9326a3996e4d7a770aac5e76a6ae3132f5ff Mon Sep 17 00:00:00 2001 From: Marc Bernard <59966492+mbtools@users.noreply.github.com> Date: Tue, 27 May 2025 18:24:26 +0200 Subject: [PATCH 08/13] Revert email prompt change --- lib/utils/auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/auth.js b/lib/utils/auth.js index a617ab9430b2a..747271169124b 100644 --- a/lib/utils/auth.js +++ b/lib/utils/auth.js @@ -51,7 +51,7 @@ const adduser = async (npm, { creds, ...opts }) => { if (!res) { const username = await read.username('Username:', creds.username) const password = await read.password('Password:', creds.password) - const email = await read.email('Email (this will be public):', creds.email) + const email = await read.email('Email: (this IS public) ', creds.email) // npm registry quirk: If you "add" an existing user with their current // password, it's effectively a login, and if that account has otp you'll // be prompted for it. From 6a78cfc70b1c51df438eb1c15c5e277e7ffd0cf7 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Mon, 2 Jun 2025 16:27:59 +0200 Subject: [PATCH 09/13] Add handling of silent prompts to display; add tests --- lib/utils/display.js | 12 ++++++++- lib/utils/read-user-info.js | 10 ++++---- test/lib/utils/display.js | 43 ++++++++++++++++++++++++++++++++ test/lib/utils/read-user-info.js | 32 ++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/lib/utils/display.js b/lib/utils/display.js index d18b1e6ef5107..164bf97a9ba5e 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -354,6 +354,10 @@ class Display { if (this.#promptCount === 0) { this.#progress.resume() } + // After silent prompts, add newline to preserve output and move cursor to next line + if (meta.silentPrompt) { + output.standard('') + } break case input.KEYS.read: { @@ -361,8 +365,14 @@ class Display { // the promise to await. resolve and reject are provided by proc-log const [res, rej, p] = args return input.start(() => p() + // User pressed Enter, resolve the promise normally .then(res) - .catch(rej)) + // User pressed ctrl+c, reject the promise and add newline to preserve output + .catch((error) => { + rej(error) + output.standard('') + throw error + })) } } }) diff --git a/lib/utils/read-user-info.js b/lib/utils/read-user-info.js index 10ad63d799c23..0af6d7b4023d7 100644 --- a/lib/utils/read-user-info.js +++ b/lib/utils/read-user-info.js @@ -1,6 +1,6 @@ const { read: _read } = require('read') const userValidate = require('npm-user-validate') -const { log, input } = require('proc-log') +const { log, input, META } = require('proc-log') const otpPrompt = `This command requires a one-time password (OTP) from your authenticator app. Enter one below. You can also pass one on the command line by appending --otp=123456. @@ -11,7 +11,10 @@ const passwordPrompt = 'npm password: ' const usernamePrompt = 'npm username: ' const emailPrompt = 'email (this IS public): ' -const read = (...args) => input.read(() => _read(...args)) +const read = (...args) => { + // Pass silent information through to determine if we need to add a newline after the prompt + return input.read(() => _read(...args), { [META]: { silentPrompt: args[0]?.silent } }) +} function readOTP (msg = otpPrompt, otp, isRetry) { if (isRetry && otp && /^[\d ]+$|^[A-Fa-f0-9]{64,64}$/.test(otp)) { @@ -24,9 +27,6 @@ function readOTP (msg = otpPrompt, otp, isRetry) { function readPassword (msg = passwordPrompt, password, isRetry) { if (isRetry && password) { - // because the prompt is silent and we need to move the cursor - // to the next line so the next output is on a new line. - process.stdout.write('\n') return password } diff --git a/test/lib/utils/display.js b/test/lib/utils/display.js index 78bffa0221d03..15d620081ebe2 100644 --- a/test/lib/utils/display.js +++ b/test/lib/utils/display.js @@ -197,3 +197,46 @@ t.test('Display.clean', async (t) => { clearOutput() } }) + +t.test('prompt functionality', async t => { + t.test('regular prompt completion works', async t => { + const { input } = await mockDisplay(t) + + const result = await input.read(() => Promise.resolve('user-input')) + + t.equal(result, 'user-input', 'should return the input result') + }) + + t.test('silent prompt completion works', async t => { + const { input, META } = await mockDisplay(t) + + const result = await input.read( + () => Promise.resolve('secret-password'), + { [META]: { silentPrompt: true } } + ) + + t.equal(result, 'secret-password', 'should return the input result for silent prompts') + }) + + t.test('metadata is correctly passed through', async t => { + const { input, META } = await mockDisplay(t) + + await input.read( + () => Promise.resolve('result1'), + { [META]: { silentPrompt: false } } + ) + t.pass('should handle silentPrompt: false metadata') + + await input.read( + () => Promise.resolve('result2'), + { [META]: {} } + ) + t.pass('should handle empty metadata') + + await input.read( + () => Promise.resolve('result3'), + { [META]: { silentPrompt: true } } + ) + t.pass('should handle silentPrompt: true metadata') + }) +}) diff --git a/test/lib/utils/read-user-info.js b/test/lib/utils/read-user-info.js index 35628f7f2faac..74463175f5e8e 100644 --- a/test/lib/utils/read-user-info.js +++ b/test/lib/utils/read-user-info.js @@ -118,3 +118,35 @@ t.test('email - invalid warns and retries', async (t) => { t.equal(result, 'foo@bar.baz', 'received the email') t.equal(logMsg, 'invalid email') }) + +t.test('read-user-info integration works', async (t) => { + t.teardown(() => { + readResult = null + readOpts = null + }) + + readResult = 'regular-input' + const username = await readUserInfo.username('Username: ') + t.equal(username, 'regular-input', 'should return username from regular prompt') + t.notOk(readOpts.silent, 'username prompt should not set silent') + + readResult = 'secret-password' + const password = await readUserInfo.password('Password: ') + t.equal(password, 'secret-password', 'should return password from silent prompt') + t.match(readOpts, { silent: true }, 'password prompt should set silent: true') +}) + +t.test('silent metadata is passed correctly by read-user-info', async (t) => { + t.teardown(() => { + readResult = null + readOpts = null + }) + + readResult = 'username' + await readUserInfo.username('Username: ') + t.notOk(readOpts?.silent, 'username prompt should not set silent') + + readResult = 'password' + await readUserInfo.password('Password: ') + t.equal(readOpts?.silent, true, 'password prompt should set silent: true') +}) From fd2ab0aec5eadb387afc746f36262b3a8f7d2d48 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Tue, 3 Jun 2025 13:08:04 +0200 Subject: [PATCH 10/13] Simplify --- lib/utils/display.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/utils/display.js b/lib/utils/display.js index 164bf97a9ba5e..5771bdd08d1fa 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -371,7 +371,6 @@ class Display { .catch((error) => { rej(error) output.standard('') - throw error })) } } From 6514150a8c871071c7a38afeb1e4b1c7fb2fc842 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Tue, 3 Jun 2025 22:50:47 +0200 Subject: [PATCH 11/13] Switch to sequential input management --- lib/utils/display.js | 42 ++++++++++++++++++++++++------------- lib/utils/read-user-info.js | 1 + 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/lib/utils/display.js b/lib/utils/display.js index 5771bdd08d1fa..3ef6f6fdccfd0 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -169,7 +169,7 @@ class Display { // progress #progress - #promptCount = 0 + #silentPrompt // options #command @@ -341,37 +341,49 @@ class Display { #inputHandler = withMeta((level, meta, ...args) => { switch (level) { case input.KEYS.start: - this.#promptCount++ log.pause() this.#outputState.buffering = true this.#progress.off() break case input.KEYS.end: - this.#promptCount-- log.resume() - output.flush() - if (this.#promptCount === 0) { - this.#progress.resume() - } - // After silent prompts, add newline to preserve output and move cursor to next line - if (meta.silentPrompt) { + if (this.#silentPrompt) { + // Add newline to preserve output output.standard('') + this.#silentPrompt = false } + 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() - // User pressed Enter, resolve the promise normally - .then(res) - // User pressed ctrl+c, reject the promise and add newline to preserve output + + // Silent inputs like password prompts require special handling + // to preserve output when users hit enter (see input.KEYS.end). + this.#silentPrompt = meta[META]?.silentPrompt || false + + // Use sequential input management to avoid race condition which causes issues + // with spinner and adding newlines + process.emit('input', 'start') + + return p() + .then((result) => { + // User hits enter, process end event and return input + process.emit('input', 'end') + res(result) + return result + }) .catch((error) => { - rej(error) + // User hits ctrl+c, add newline to preserve output output.standard('') - })) + this.#silentPrompt = false + process.emit('input', 'end') + rej(error) + }) } } }) diff --git a/lib/utils/read-user-info.js b/lib/utils/read-user-info.js index 0af6d7b4023d7..de6d9adfd5309 100644 --- a/lib/utils/read-user-info.js +++ b/lib/utils/read-user-info.js @@ -13,6 +13,7 @@ const emailPrompt = 'email (this IS public): ' const read = (...args) => { // Pass silent information through to determine if we need to add a newline after the prompt + // Rename parameter to avoid confusion with silent logging in Display return input.read(() => _read(...args), { [META]: { silentPrompt: args[0]?.silent } }) } From 7b741fd94ab987af5bb9ac552019fa23deea3097 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Tue, 3 Jun 2025 23:07:53 +0200 Subject: [PATCH 12/13] Revert token test changes --- test/lib/commands/token.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/lib/commands/token.js b/test/lib/commands/token.js index 1290a5ee9cb17..f60a938b5b34b 100644 --- a/test/lib/commands/token.js +++ b/test/lib/commands/token.js @@ -265,6 +265,7 @@ t.test('token create', async t => { registry.createToken({ password, cidr }) await npm.exec('token', ['create']) t.strictSame(outputs, [ + '', 'Created publish token n3wt0k3n', 'with IP whitelist: 10.0.0.0/8,192.168.1.0/24', ]) @@ -291,6 +292,7 @@ t.test('token create read only', async t => { registry.createToken({ readonly: true, password }) await npm.exec('token', ['create']) t.strictSame(outputs, [ + '', 'Created read only token n3wt0k3n', ]) }) @@ -347,10 +349,10 @@ t.test('token create parseable output', async t => { }, { replace: true }) registry.createToken({ password, cidr }) await npm.exec('token', ['create']) - t.equal(outputs[0], 'token\tn3wt0k3n') - t.ok(outputs[1].startsWith('created\t')) - t.equal(outputs[2], 'readonly\tfalse') - t.equal(outputs[3], 'cidr_whitelist\t10.0.0.0/8,192.168.1.0/24') + t.equal(outputs[1], 'token\tn3wt0k3n') + t.ok(outputs[2].startsWith('created\t')) + t.equal(outputs[3], 'readonly\tfalse') + t.equal(outputs[4], 'cidr_whitelist\t10.0.0.0/8,192.168.1.0/24') }) t.test('token create ipv6 cidr', async t => { From 159dabb09054b91dc45e96c4eb72805fbadd1995 Mon Sep 17 00:00:00 2001 From: Marc Bernard Date: Wed, 2 Jul 2025 15:32:05 +0200 Subject: [PATCH 13/13] Remove META logic, pass input options --- lib/utils/display.js | 20 +++++++------------- lib/utils/read-user-info.js | 9 +++------ test/lib/utils/display.js | 18 +++++++++--------- 3 files changed, 19 insertions(+), 28 deletions(-) diff --git a/lib/utils/display.js b/lib/utils/display.js index 3ef6f6fdccfd0..a682b4dbb2fb2 100644 --- a/lib/utils/display.js +++ b/lib/utils/display.js @@ -169,7 +169,6 @@ class Display { // progress #progress - #silentPrompt // options #command @@ -348,23 +347,19 @@ class Display { case input.KEYS.end: log.resume() - if (this.#silentPrompt) { - // Add newline to preserve output + // For silent prompts (like password), add newline to preserve output + if (args[0]?.[0]?.silent) { output.standard('') - this.#silentPrompt = false } 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 - - // Silent inputs like password prompts require special handling - // to preserve output when users hit enter (see input.KEYS.end). - this.#silentPrompt = meta[META]?.silentPrompt || false + // The convention when calling input.read is to pass in a fn that returns + // the promise to await. resolve and reject are provided by proc-log. + // The last argument are the options passed to read which includes the silent flag + const [res, rej, p, options] = args // Use sequential input management to avoid race condition which causes issues // with spinner and adding newlines @@ -373,14 +368,13 @@ class Display { return p() .then((result) => { // User hits enter, process end event and return input - process.emit('input', 'end') + process.emit('input', 'end', options) res(result) return result }) .catch((error) => { // User hits ctrl+c, add newline to preserve output output.standard('') - this.#silentPrompt = false process.emit('input', 'end') rej(error) }) diff --git a/lib/utils/read-user-info.js b/lib/utils/read-user-info.js index de6d9adfd5309..d20b54526e90e 100644 --- a/lib/utils/read-user-info.js +++ b/lib/utils/read-user-info.js @@ -1,6 +1,6 @@ const { read: _read } = require('read') const userValidate = require('npm-user-validate') -const { log, input, META } = require('proc-log') +const { log, input } = require('proc-log') const otpPrompt = `This command requires a one-time password (OTP) from your authenticator app. Enter one below. You can also pass one on the command line by appending --otp=123456. @@ -11,11 +11,8 @@ const passwordPrompt = 'npm password: ' const usernamePrompt = 'npm username: ' const emailPrompt = 'email (this IS public): ' -const read = (...args) => { - // Pass silent information through to determine if we need to add a newline after the prompt - // Rename parameter to avoid confusion with silent logging in Display - return input.read(() => _read(...args), { [META]: { silentPrompt: args[0]?.silent } }) -} +// Pass options through so we can differentiate between regular and silent prompts +const read = (...args) => input.read(() => _read(...args), args) function readOTP (msg = otpPrompt, otp, isRetry) { if (isRetry && otp && /^[\d ]+$|^[A-Fa-f0-9]{64,64}$/.test(otp)) { diff --git a/test/lib/utils/display.js b/test/lib/utils/display.js index 15d620081ebe2..74b76a8e56240 100644 --- a/test/lib/utils/display.js +++ b/test/lib/utils/display.js @@ -208,35 +208,35 @@ t.test('prompt functionality', async t => { }) t.test('silent prompt completion works', async t => { - const { input, META } = await mockDisplay(t) + const { input } = await mockDisplay(t) const result = await input.read( () => Promise.resolve('secret-password'), - { [META]: { silentPrompt: true } } + { silent: true } ) t.equal(result, 'secret-password', 'should return the input result for silent prompts') }) t.test('metadata is correctly passed through', async t => { - const { input, META } = await mockDisplay(t) + const { input } = await mockDisplay(t) await input.read( () => Promise.resolve('result1'), - { [META]: { silentPrompt: false } } + { silent: false } ) - t.pass('should handle silentPrompt: false metadata') + t.pass('should handle silent false option') await input.read( () => Promise.resolve('result2'), - { [META]: {} } + {} ) - t.pass('should handle empty metadata') + t.pass('should handle empty options') await input.read( () => Promise.resolve('result3'), - { [META]: { silentPrompt: true } } + { silent: true } ) - t.pass('should handle silentPrompt: true metadata') + t.pass('should handle silent true option') }) })