Skip to content

Commit 06fd788

Browse files
authoredJun 22, 2022
feat: prompt before opening web-login URL when performing login/adduser (#4960)
Prompt before opening web-login URL when performing login/adduser
1 parent aab4079 commit 06fd788

File tree

5 files changed

+255
-3
lines changed

5 files changed

+255
-3
lines changed
 

‎lib/auth/legacy.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const profile = require('npm-profile')
22
const log = require('../utils/log-shim')
3-
const openUrl = require('../utils/open-url.js')
3+
const openUrlPrompt = require('../utils/open-url-prompt.js')
44
const read = require('../utils/read-user-info.js')
55

66
const loginPrompter = async (creds) => {
@@ -47,7 +47,15 @@ const login = async (npm, opts) => {
4747
return newUser
4848
}
4949

50-
const openerPromise = (url) => openUrl(npm, url, 'to complete your login please visit')
50+
const openerPromise = (url, emitter) =>
51+
openUrlPrompt(
52+
npm,
53+
url,
54+
'Authenticate your account at',
55+
'Press ENTER to open in the browser...',
56+
emitter
57+
)
58+
5159
try {
5260
res = await profile.login(openerPromise, loginPrompter, opts)
5361
} catch (err) {

‎lib/utils/open-url-prompt.js

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const readline = require('readline')
2+
const opener = require('opener')
3+
4+
function print (npm, title, url) {
5+
const json = npm.config.get('json')
6+
7+
const message = json ? JSON.stringify({ title, url }) : `${title}:\n${url}`
8+
9+
npm.output(message)
10+
}
11+
12+
// Prompt to open URL in browser if possible
13+
const promptOpen = async (npm, url, title, prompt, emitter) => {
14+
const browser = npm.config.get('browser')
15+
const isInteractive = process.stdin.isTTY === true && process.stdout.isTTY === true
16+
17+
try {
18+
if (!/^https?:$/.test(new URL(url).protocol)) {
19+
throw new Error()
20+
}
21+
} catch (_) {
22+
throw new Error('Invalid URL: ' + url)
23+
}
24+
25+
print(npm, title, url)
26+
27+
if (browser === false || !isInteractive) {
28+
return
29+
}
30+
31+
const rl = readline.createInterface({
32+
input: process.stdin,
33+
output: process.stdout,
34+
})
35+
36+
const tryOpen = await new Promise(resolve => {
37+
rl.question(prompt, () => {
38+
resolve(true)
39+
})
40+
41+
if (emitter && emitter.addListener) {
42+
emitter.addListener('abort', () => {
43+
rl.close()
44+
45+
// clear the prompt line
46+
npm.output('')
47+
48+
resolve(false)
49+
})
50+
}
51+
})
52+
53+
if (!tryOpen) {
54+
return
55+
}
56+
57+
const command = browser === true ? null : browser
58+
await new Promise((resolve, reject) => {
59+
opener(url, { command }, err => {
60+
if (err) {
61+
return reject(err)
62+
}
63+
64+
return resolve()
65+
})
66+
})
67+
}
68+
69+
module.exports = promptOpen
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/* IMPORTANT
2+
* This snapshot file is auto-generated, but designed for humans.
3+
* It should be checked into source control and tracked carefully.
4+
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5+
* Make sure to inspect the output below. Do not ignore changes!
6+
*/
7+
'use strict'
8+
exports[`test/lib/utils/open-url-prompt.js TAP opens a url > must match snapshot 1`] = `
9+
Array [
10+
Array [
11+
String(
12+
npm home:
13+
https://www.npmjs.com
14+
),
15+
],
16+
]
17+
`
18+
19+
exports[`test/lib/utils/open-url-prompt.js TAP prints json output > must match snapshot 1`] = `
20+
Array [
21+
Array [
22+
"{\\"title\\":\\"npm home\\",\\"url\\":\\"https://www.npmjs.com\\"}",
23+
],
24+
]
25+
`

‎test/lib/auth/legacy.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const legacy = t.mock('../../../lib/auth/legacy.js', {
1212
},
1313
},
1414
'npm-profile': profile,
15-
'../../../lib/utils/open-url.js': (npm, url, msg) => {
15+
'../../../lib/utils/open-url-prompt.js': (_npm, url) => {
1616
if (!url) {
1717
throw Object.assign(new Error('failed open url'), { code: 'ERROR' })
1818
}

‎test/lib/utils/open-url-prompt.js

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
const t = require('tap')
2+
const mockGlobals = require('../../fixtures/mock-globals.js')
3+
const EventEmitter = require('events')
4+
5+
const OUTPUT = []
6+
const output = (...args) => OUTPUT.push(args)
7+
const npm = {
8+
_config: {
9+
json: false,
10+
browser: true,
11+
},
12+
config: {
13+
get: k => npm._config[k],
14+
set: (k, v) => {
15+
npm._config[k] = v
16+
},
17+
},
18+
output,
19+
}
20+
21+
let openerUrl = null
22+
let openerOpts = null
23+
let openerResult = null
24+
const opener = (url, opts, cb) => {
25+
openerUrl = url
26+
openerOpts = opts
27+
return cb(openerResult)
28+
}
29+
30+
let questionShouldResolve = true
31+
const readline = {
32+
createInterface: () => ({
33+
question: (_q, cb) => {
34+
if (questionShouldResolve === true) {
35+
cb()
36+
}
37+
},
38+
close: () => {},
39+
}),
40+
}
41+
42+
const openUrlPrompt = t.mock('../../../lib/utils/open-url-prompt.js', {
43+
opener,
44+
readline,
45+
})
46+
47+
mockGlobals(t, {
48+
'process.stdin.isTTY': true,
49+
'process.stdout.isTTY': true,
50+
})
51+
52+
t.test('does not open a url in non-interactive environments', async t => {
53+
t.teardown(() => {
54+
openerUrl = null
55+
openerOpts = null
56+
OUTPUT.length = 0
57+
})
58+
59+
mockGlobals(t, {
60+
'process.stdin.isTTY': false,
61+
'process.stdout.isTTY': false,
62+
})
63+
64+
await openUrlPrompt(npm, 'https://www.npmjs.com', 'npm home', 'prompt')
65+
t.equal(openerUrl, null, 'did not open')
66+
t.same(openerOpts, null, 'did not open')
67+
})
68+
69+
t.test('opens a url', async t => {
70+
t.teardown(() => {
71+
openerUrl = null
72+
openerOpts = null
73+
OUTPUT.length = 0
74+
npm._config.browser = true
75+
})
76+
77+
npm._config.browser = 'browser'
78+
await openUrlPrompt(npm, 'https://www.npmjs.com', 'npm home', 'prompt')
79+
t.equal(openerUrl, 'https://www.npmjs.com', 'opened the given url')
80+
t.same(openerOpts, { command: 'browser' }, 'passed command as null (the default)')
81+
t.matchSnapshot(OUTPUT)
82+
})
83+
84+
t.test('prints json output', async t => {
85+
t.teardown(() => {
86+
openerUrl = null
87+
openerOpts = null
88+
OUTPUT.length = 0
89+
npm._config.json = false
90+
})
91+
92+
npm._config.json = true
93+
await openUrlPrompt(npm, 'https://www.npmjs.com', 'npm home', 'prompt')
94+
t.matchSnapshot(OUTPUT)
95+
})
96+
97+
t.test('returns error for non-https url', async t => {
98+
t.teardown(() => {
99+
openerUrl = null
100+
openerOpts = null
101+
OUTPUT.length = 0
102+
})
103+
await t.rejects(
104+
openUrlPrompt(npm, 'ftp://www.npmjs.com', 'npm home', 'prompt'),
105+
/Invalid URL/,
106+
'got the correct error'
107+
)
108+
t.equal(openerUrl, null, 'did not open')
109+
t.same(openerOpts, null, 'did not open')
110+
t.same(OUTPUT, [], 'printed no output')
111+
})
112+
113+
t.test('does not open url if canceled', async t => {
114+
t.teardown(() => {
115+
openerUrl = null
116+
openerOpts = null
117+
OUTPUT.length = 0
118+
questionShouldResolve = true
119+
})
120+
121+
questionShouldResolve = false
122+
const emitter = new EventEmitter()
123+
124+
const open = openUrlPrompt(npm, 'https://www.npmjs.com', 'npm home', 'prompt', emitter)
125+
126+
emitter.emit('abort')
127+
128+
await open
129+
130+
t.equal(openerUrl, null, 'did not open')
131+
t.same(openerOpts, null, 'did not open')
132+
})
133+
134+
t.test('returns error when opener errors', async t => {
135+
t.teardown(() => {
136+
openerUrl = null
137+
openerOpts = null
138+
openerResult = null
139+
OUTPUT.length = 0
140+
})
141+
142+
openerResult = new Error('Opener failed')
143+
144+
await t.rejects(
145+
openUrlPrompt(npm, 'https://www.npmjs.com', 'npm home', 'prompt'),
146+
/Opener failed/,
147+
'got the correct error'
148+
)
149+
t.equal(openerUrl, 'https://www.npmjs.com', 'did not open')
150+
})

0 commit comments

Comments
 (0)
Please sign in to comment.