diff --git a/lib/utils/auth.js b/lib/utils/auth.js index 55e40d5c3c269..ac61b7aa6185d 100644 --- a/lib/utils/auth.js +++ b/lib/utils/auth.js @@ -49,6 +49,12 @@ const login = async (npm, { creds, ...opts }) => { // auth type !== web or ENYI error w/ web login if (!res) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw Object.assign(new Error( + 'npm login requires an interactive terminal to prompt for credentials.' + ), { code: 'ENOTTYAUTH' }) + } + const username = await read.username('Username:', creds.username) const password = await read.password('Password:', creds.password) res = await otplease(npm, opts, (reqOpts) => loginCouch(username, password, reqOpts)) diff --git a/lib/utils/error-message.js b/lib/utils/error-message.js index 41e35bd78ac46..6b8d354a125f8 100644 --- a/lib/utils/error-message.js +++ b/lib/utils/error-message.js @@ -271,6 +271,15 @@ const errorMessage = (er, npm) => { detail.push(['need auth', 'You need to authorize this machine using `npm login`']) break + case 'ENOTTYAUTH': + summary.push(['need auth', er.message]) + detail.push(['need auth', [ + 'Create a granular access token at https://www.npmjs.com/, then set it with:', + ' npm config set //registry.npmjs.org/:_authToken=', + 'or by setting the NPM_TOKEN environment variable.', + ].join('\n')]) + break + case 'ECONNRESET': case 'ENOTFOUND': case 'ETIMEDOUT': diff --git a/tap-snapshots/test/lib/utils/error-message.js.test.cjs b/tap-snapshots/test/lib/utils/error-message.js.test.cjs index a63412c96ea4a..1eb1d9d7244fb 100644 --- a/tap-snapshots/test/lib/utils/error-message.js.test.cjs +++ b/tap-snapshots/test/lib/utils/error-message.js.test.cjs @@ -1188,6 +1188,28 @@ Object { ` exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 13`] = ` +Object { + "detail": Array [ + Array [ + "network", + String( + This is a problem related to network connectivity. + In most cases you are behind a proxy or have bad network settings. + + If you are behind a proxy, please make sure that the 'proxy' config is set properly. See: 'npm help config' + ), + ], + ], + "summary": Array [ + Array [ + "network", + "foo", + ], + ], +} +` + +exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 14`] = ` Object { "detail": Array [ Array [ @@ -1212,7 +1234,7 @@ Object { } ` -exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 14`] = ` +exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 15`] = ` Object { "detail": Array [ Array [ @@ -1232,7 +1254,7 @@ Object { } ` -exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 15`] = ` +exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 16`] = ` Object { "detail": Array [ Array [ @@ -1249,7 +1271,7 @@ Object { } ` -exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 16`] = ` +exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 17`] = ` Object { "detail": Array [ Array [ @@ -1270,26 +1292,6 @@ Object { } ` -exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 17`] = ` -Object { - "detail": Array [ - Array [ - "typeerror", - String( - This is an error with npm itself. Please report this error at: - https://github.com/npm/cli/issues - ), - ], - ], - "summary": Array [ - Array [ - "typeerror", - "dummy stack trace", - ], - ], -} -` - exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 18`] = ` Object { "detail": Array [ @@ -1372,6 +1374,26 @@ Object { ` exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 21`] = ` +Object { + "detail": Array [ + Array [ + "typeerror", + String( + This is an error with npm itself. Please report this error at: + https://github.com/npm/cli/issues + ), + ], + ], + "summary": Array [ + Array [ + "typeerror", + "dummy stack trace", + ], + ], +} +` + +exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 22`] = ` Object { "detail": Array [ Array [ @@ -1388,7 +1410,7 @@ Object { } ` -exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 22`] = ` +exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 23`] = ` Object { "detail": Array [ Array [ @@ -1405,7 +1427,7 @@ Object { } ` -exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 23`] = ` +exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 24`] = ` Object { "detail": Array [ Array [ @@ -1569,18 +1591,17 @@ exports[`test/lib/utils/error-message.js TAP just simple messages > must match s Object { "detail": Array [ Array [ - "network", + "need auth", String( - This is a problem related to network connectivity. - In most cases you are behind a proxy or have bad network settings. - - If you are behind a proxy, please make sure that the 'proxy' config is set properly. See: 'npm help config' + Create a granular access token at https://www.npmjs.com/, then set it with: + npm config set //registry.npmjs.org/:_authToken= + or by setting the NPM_TOKEN environment variable. ), ], ], "summary": Array [ Array [ - "network", + "need auth", "foo", ], ], diff --git a/test/lib/commands/login.js b/test/lib/commands/login.js index 623bc5845708f..dec8fdfb0d458 100644 --- a/test/lib/commands/login.js +++ b/test/lib/commands/login.js @@ -12,12 +12,15 @@ const mockLogin = async (t, { stdin: stdinLines, registry: registryUrl, ...optio let stdin if (stdinLines) { stdin = new stream.PassThrough() + stdin.isTTY = true for (const l of stdinLines) { stdin.write(l + '\n') } + const stdout = new stream.PassThrough() // to quiet readline + stdout.isTTY = true mockGlobals(t, { 'process.stdin': stdin, - 'process.stdout': new stream.PassThrough(), // to quiet readline + 'process.stdout': stdout, }, { replace: true }) } const mock = await loadMockNpm(t, { diff --git a/test/lib/utils/auth.js b/test/lib/utils/auth.js index 01d254ad8a0e7..ea129ff21a0c7 100644 --- a/test/lib/utils/auth.js +++ b/test/lib/utils/auth.js @@ -141,3 +141,108 @@ t.test('does not prompt if stdin or stdout is not a tty', async (t) => { }, }, fn), { message: 'nope' }, 'rejects with the original error') }) + +const setupLogin = async (t, { creds = {}, loginWeb, ...rest }, opts = {}) => { + const { login } = tmock(t, '{LIB}/utils/auth.js', { + '{LIB}/utils/read-user-info.js': { + username: async () => 'foo', + password: async () => 'bar', + }, + '{LIB}/utils/open-url.js': { + createOpener: () => () => {}, + }, + 'npm-profile': { + loginCouch: async () => ({ token: 'test-token' }), + ...(loginWeb ? { loginWeb } : {}), + }, + }) + const { npm } = await setupMockNpm(t, { + ...rest, + config: { 'auth-type': 'legacy', ...rest.config }, + }) + return login(npm, { creds, registry: 'https://registry.npmjs.org/', ...opts }) +} + +t.test('login throws a clear error when stdin is not a tty', async (t) => { + await t.rejects(setupLogin(t, { + globals: { + 'process.stdin': { isTTY: false }, + 'process.stdout': { isTTY: true }, + }, + }), { + code: 'ENOTTYAUTH', + message: /requires an interactive terminal/, + }, 'rejects with a clear, actionable error instead of hanging') +}) + +t.test('login throws a clear error when stdout is not a tty', async (t) => { + await t.rejects(setupLogin(t, { + globals: { + 'process.stdin': { isTTY: true }, + 'process.stdout': { isTTY: false }, + }, + }), { + code: 'ENOTTYAUTH', + message: /requires an interactive terminal/, + }, 'rejects with a clear, actionable error instead of hanging') +}) + +t.test('login throws a clear error when neither stdin nor stdout is a tty', async (t) => { + await t.rejects(setupLogin(t, { + globals: { + 'process.stdin': { isTTY: false }, + 'process.stdout': { isTTY: false }, + }, + }), { + code: 'ENOTTYAUTH', + message: /requires an interactive terminal/, + }, 'rejects with a clear, actionable error instead of hanging') +}) + +t.test('login succeeds with couch when stdin and stdout are ttys', async (t) => { + const result = await setupLogin(t, { + globals: { + 'process.stdin': { isTTY: true }, + 'process.stdout': { isTTY: true }, + }, + }) + + t.strictSame(result, { + message: 'Logged in on https://registry.npmjs.org/.', + newCreds: { token: 'test-token' }, + }) +}) + +t.test('login throws a clear error for the web login ENYI fallback when not a tty', async (t) => { + await t.rejects(setupLogin(t, { + config: { 'auth-type': 'web' }, + loginWeb: async () => { + throw Object.assign(new Error('web login not supported'), { code: 'ENYI' }) + }, + globals: { + 'process.stdin': { isTTY: false }, + 'process.stdout': { isTTY: false }, + }, + }), { + code: 'ENOTTYAUTH', + message: /requires an interactive terminal/, + }, 'rejects with a clear, actionable error instead of hanging on the couch fallback') +}) + +t.test('login falls back to couch after web login ENYI when a tty', async (t) => { + const result = await setupLogin(t, { + config: { 'auth-type': 'web' }, + loginWeb: async () => { + throw Object.assign(new Error('web login not supported'), { code: 'ENYI' }) + }, + globals: { + 'process.stdin': { isTTY: true }, + 'process.stdout': { isTTY: true }, + }, + }) + + t.strictSame(result, { + message: 'Logged in on https://registry.npmjs.org/.', + newCreds: { token: 'test-token' }, + }) +}) diff --git a/test/lib/utils/error-message.js b/test/lib/utils/error-message.js index 44a57eca645d7..f188befce6eca 100644 --- a/test/lib/utils/error-message.js +++ b/test/lib/utils/error-message.js @@ -59,6 +59,7 @@ t.test('just simple messages', async t => { 'EISGIT', 'EEXIST', 'ENEEDAUTH', + 'ENOTTYAUTH', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT',