feat(auth): keyboard event to open browser (#11148)

* feat(auth): add "Press ENTER to open in browser" during web authentication

During web-based authentication (login, publish), users can now press
ENTER to open the authentication URL in their default browser. The
background token polling continues uninterrupted, so users who prefer
to authenticate on their phone can still do so without pressing anything.

The implementation uses Node's readline module (not raw mode), so Ctrl+C
and Ctrl+Z continue to work normally. It is fully error-tolerant: if the
keyboard listener or browser opening fails, a warning is printed and the
polling continues.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix(auth): inject readline and execFile directly, not wrapper functions

Address review feedback:
- Remove defaultListenForEnter and defaultOpenBrowser wrapper functions
- Inject readline module and execFile function directly via context
- DEFAULT_CONTEXT now references modules directly (no closures)
- Use switch for platform detection, default = no browser prompt
- Rename pollWithBrowserOpen → offerToOpenBrowser (clearer name)
- Add platform-specific tests (darwin, win32, linux, freebsd)
- Use PassThrough streams for stdin mocks in tests

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix(auth): fix CI type errors in test mocks

- Type jest.fn() mocks for readline.createInterface properly
- Use PassThrough streams for stdin mocks in releasing/commands tests

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* refactor(auth): use generic Stdin parameter to eliminate PassThrough in tests

Per review feedback, add a generic Stdin type parameter to context
interfaces. This ties process.stdin and readline.createInterface together
through the same type, so tests can use simple { isTTY: true } mocks
instead of requiring PassThrough streams.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix(auth): propagate Stdin generic to releasing/commands OtpContext

The OtpContext in releasing/commands extends BaseOtpContext from
web-auth. Now that BaseOtpContext is generic, the local OtpContext
and publishWithOtpHandling must also be generic so tests can use
simple stdin mocks without PassThrough.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix: sort imports in releasing/commands otp.ts

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* refactor(auth): use .bind() for readline injection instead of generics

Per review feedback, revert the generic Stdin approach and instead use
readline.createInterface.bind(null, { input: process.stdin }) as the
injectable dependency. This avoids generics proliferation while keeping
the context clean — no arrow functions or closures in DEFAULT_CONTEXT.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* feat(publish): add "Press ENTER to open in browser" during publish OTP

Wire up createReadlineInterface and execFile in the publish
SHARED_CONTEXT so that pnpm publish also offers to open the browser
during web-based OTP authentication.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix(auth): improve browser-open prompt message

Change "Press ENTER to open in browser..." to
"Press ENTER to open the URL in your browser."

The old message implied the user should press Enter. The new wording
presents it as an available action, not an instruction — users can
also scan the QR code or copy-paste the URL.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* style: remove unnecessary arrow wrapper around createMockReadlineInterface

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* docs: explain why Enter keypress is fire-and-forget, not awaited

Add a comment explaining that only pollPromise is awaited — the Enter
listener is intentionally not part of a Promise.all. This prevents a
future refactor from reintroducing the npm bug where authentication
blocks until Enter is pressed, even when the user authenticates on
another device.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* docs: add permalink to npm's Promise.all bug in comment

Link to the specific npm-profile commit (d1a48be4259) so the comment
remains accurate even if npm fixes the bug in the future.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix: correct line numbers in npm-profile permalink (L85-L98)

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* style: apply review suggestion for npm-profile permalink format

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* style: remove duplicate line in npm-profile comment

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix: shadow global process instead of renaming to proc

Destructure as `process` (not `proc`) so the global `process` is
shadowed, preventing accidental direct access to it.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix: merge process fields in test mock contexts

Restructure createMockContext to merge process fields instead of
replacing the entire object. Tests that only need to override
platform or stdin no longer need to redundantly provide the other.

Also adds a test for undefined platform (default: case).

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix: use Omit+Partial for process overrides in test mock contexts

The process field spread `...overrides?.process` merges at runtime but
TypeScript still requires all fields in the override type. Fix by typing
the process override as Partial via Omit<..., 'process'> & { process?: Partial<...> }.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* refactor: extract a type alias

* refactor: extract MockContextOverrides type alias in remaining tests

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* refactor(auth): extract process types, use NodeJS.Platform, clean up tests

- Extract OfferToOpenBrowserProcess interface from inline process type
- Extract LoginProcess interface from inline process type in LoginContext
- Use NodeJS.Platform instead of string for platform fields (prevents typos)
- Rename simulateEnter → simulateEnterKeypress (clarify it's the key)
- Convert single-return functions to arrow expressions in tests
- Update test descriptions to say "Enter key" / "Enter keypress"

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* refactor(auth): rename offerToOpenBrowser → promptBrowserOpen

Per review feedback, "offer to open browser" was mouthful. Renamed
function, file, and all associated types (OfferToOpenBrowser* →
PromptBrowserOpen*).

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* docs: drop "IMPORTANT"

* refactor(auth): extract OtpProcess interface from inline process type

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix(auth): validate authUrl before passing to execFile

On Windows, cmd.exe re-parses execFile arguments with full shell
grammar, so metacharacters (&, |, ^, etc.) in the URL would be
interpreted as operators. Validate that authUrl is a well-formed
http(s) URL before passing it to the platform browser command.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* test(auth): add regression test for URLs with query parameters on win32

Verifies that URLs containing & and other query string characters are
passed through to execFile as-is on the win32 platform.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix(auth): escape cmd.exe metacharacters in Windows browser open URL

On Windows, cmd.exe re-parses execFile arguments and treats & | < > ^ %
as operators. Escape these with ^ so query strings in auth URLs
(e.g. ?token=abc&redirect=...) are not split by cmd.exe.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix(auth): use canonicalized URL and expand cmd.exe escape set

- Use parsedUrl.href (canonicalized by new URL()) instead of the raw
  authUrl string, ensuring percent-encoding of spaces and special chars.
- Expand cmd.exe metacharacter escaping to include () and ! in addition
  to & | < > ^ %, covering grouping operators and delayed expansion.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* docs(auth): document Windows browser-opening edge cases

Explain why cmd /c start is used instead of ShellExecuteW (not
callable from Node.js without a native addon), why alternatives
like explorer.exe, rundll32, and PowerShell are unreliable, and
note that a Rust/N-API addon could replace this in the future.

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

* fix: fix cspell errors in Windows browser-open comment

Reword to avoid unknown words "rundll" and "metacharacter".

https://claude.ai/code/session_01UtDnjrNQ2Cc3GLAPR8BrrW

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Khải
2026-04-03 06:36:00 +07:00
committed by GitHub
parent 4ba4bf33c8
commit de3dc74439
10 changed files with 615 additions and 23 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/network.web-auth": minor
"@pnpm/auth.commands": minor
"pnpm": minor
---
During web-based authentication (`pnpm login`, `pnpm publish`), users can now press ENTER to open the authentication URL in their default browser. The background polling continues uninterrupted, so users who prefer to authenticate on their phone can still do so without pressing anything.

View File

@@ -1,4 +1,6 @@
import { execFile } from 'node:child_process'
import path from 'node:path'
import readline from 'node:readline'
import util from 'node:util'
import { docsUrl } from '@pnpm/cli.utils'
@@ -9,6 +11,9 @@ import { fetch } from '@pnpm/network.fetch'
import {
generateQrCode,
pollForWebAuthToken,
promptBrowserOpen,
type PromptBrowserOpenExecFile,
type PromptBrowserOpenReadlineInterface,
SyntheticOtpError,
type WebAuthFetchOptions,
withOtpHandling,
@@ -121,14 +126,22 @@ export interface LoginFetchOptions {
timeout?: number
}
export interface LoginProcess {
platform: NodeJS.Platform
stdin: { isTTY?: boolean }
stdout: { isTTY?: boolean }
}
export interface LoginContext {
Date: LoginDate
setTimeout: (cb: () => void, ms: number) => void
createReadlineInterface: () => PromptBrowserOpenReadlineInterface
enquirer: LoginEnquirer
execFile: PromptBrowserOpenExecFile
fetch: (url: string, options?: LoginFetchOptions) => Promise<LoginFetchResponse>
globalInfo: (message: string) => void
globalWarn: (message: string) => void
process: Record<'stdin' | 'stdout', { isTTY?: boolean }>
process: LoginProcess
readIniFile: (configPath: string) => Promise<object>
writeIniFile: (configPath: string, settings: Record<string, unknown>) => Promise<void>
}
@@ -136,7 +149,9 @@ export interface LoginContext {
export const DEFAULT_CONTEXT: LoginContext = {
Date,
setTimeout,
createReadlineInterface: readline.createInterface.bind(null, { input: process.stdin }),
enquirer,
execFile,
fetch,
globalInfo,
globalWarn,
@@ -196,7 +211,7 @@ export async function login ({ context = DEFAULT_CONTEXT, opts }: LoginParams):
}
interface WebLoginParams {
context: Pick<LoginContext, 'Date' | 'setTimeout' | 'fetch' | 'globalInfo'>
context: Pick<LoginContext, 'Date' | 'setTimeout' | 'createReadlineInterface' | 'execFile' | 'fetch' | 'globalInfo' | 'globalWarn' | 'process'>
fetchOptions: WebAuthFetchOptions
registry: string
}
@@ -237,11 +252,17 @@ async function webLogin ({
const qrCode = generateQrCode(body.loginUrl)
globalInfo(`Authenticate your account at:\n${body.loginUrl}\n\n${qrCode}`)
return pollForWebAuthToken({ context, doneUrl: body.doneUrl, fetchOptions })
const pollPromise = pollForWebAuthToken({ context, doneUrl: body.doneUrl, fetchOptions })
return promptBrowserOpen({
authUrl: body.loginUrl,
context,
pollPromise,
})
}
interface ClassicLoginParams {
context: Pick<LoginContext, 'Date' | 'setTimeout' | 'enquirer' | 'fetch' | 'globalInfo' | 'globalWarn' | 'process'>
context: Pick<LoginContext, 'Date' | 'setTimeout' | 'createReadlineInterface' | 'enquirer' | 'execFile' | 'fetch' | 'globalInfo' | 'globalWarn' | 'process'>
fetchOptions: WebAuthFetchOptions
registry: string
}

View File

@@ -9,9 +9,16 @@ const TEST_CONTEXT: LoginContext = {
setTimeout: cb => {
cb()
},
createReadlineInterface: () => ({
once: () => {},
close: () => {},
}),
enquirer: { prompt: async () => {
throw new Error('Unexpected call to enquirer.prompt')
} },
execFile: () => {
throw new Error('Unexpected call to execFile')
},
fetch: async url => {
throw new Error(`Unexpected call to fetch: ${url}`)
},
@@ -22,6 +29,7 @@ const TEST_CONTEXT: LoginContext = {
throw new Error(`Unexpected call to globalWarn: ${message}`)
},
process: {
platform: 'linux',
stdin: { isTTY: true },
stdout: { isTTY: true },
},
@@ -62,9 +70,17 @@ const createMockResponse = (init: {
}
}
const createMockContext = (overrides?: Partial<LoginContext>): LoginContext => ({
type MockContextOverrides = Omit<Partial<LoginContext>, 'process'> & {
process?: Partial<LoginContext['process']>
}
const createMockContext = (overrides?: MockContextOverrides): LoginContext => ({
...TEST_CONTEXT,
...overrides,
process: {
...TEST_CONTEXT.process,
...overrides?.process,
},
})
describe('login', () => {
@@ -72,7 +88,6 @@ describe('login', () => {
const context = createMockContext({
process: {
stdin: { isTTY: false },
stdout: { isTTY: true },
},
})
const opts = { configDir: '/mock/config', dir: '/mock', rawConfig: {} }
@@ -123,7 +138,10 @@ describe('login', () => {
expect(savedSettings).toMatchObject({
'//example.com/npm/:_authToken': 'web-auth-token-123',
})
expect(globalInfo.mock.calls).toEqual([[expect.stringContaining('https://example.com/auth/login')]])
expect(globalInfo.mock.calls).toEqual([
[expect.stringContaining('https://example.com/auth/login')],
['Press ENTER to open the URL in your browser.'],
])
})
it('should fall back to classic login when web login returns 404', async () => {
@@ -559,6 +577,9 @@ describe('login', () => {
const promise = login({ context, opts })
await expect(promise).rejects.toHaveProperty(['code'], 'EACCES')
await expect(promise).rejects.toHaveProperty(['message'], 'EACCES: permission denied')
expect(globalInfo.mock.calls).toEqual([[expect.stringContaining('https://example.org/auth/login')]])
expect(globalInfo.mock.calls).toEqual([
[expect.stringContaining('https://example.org/auth/login')],
['Press ENTER to open the URL in your browser.'],
])
})
})

View File

@@ -7,6 +7,14 @@ export {
type WebAuthFetchResponse,
type WebAuthFetchResponseHeaders,
} from './pollForWebAuthToken.js'
export {
promptBrowserOpen,
type PromptBrowserOpenContext,
type PromptBrowserOpenExecFile,
type PromptBrowserOpenParams,
type PromptBrowserOpenProcess,
type PromptBrowserOpenReadlineInterface,
} from './promptBrowserOpen.js'
export { WebAuthTimeoutError } from './WebAuthTimeoutError.js'
export {
isOtpError,
@@ -14,6 +22,7 @@ export {
type OtpEnquirer,
type OtpHandlingParams,
OtpNonInteractiveError,
type OtpProcess,
type OtpPromptOptions,
type OtpPromptResponse,
OtpSecondChallengeError,

View File

@@ -0,0 +1,153 @@
export interface PromptBrowserOpenReadlineInterface {
once: (event: string, listener: () => void) => void
close: () => void
}
export interface PromptBrowserOpenExecFile {
(file: string, args: readonly string[], callback: (error: Error | null) => void): unknown
}
export interface PromptBrowserOpenProcess {
platform?: NodeJS.Platform
stdin: { isTTY?: boolean }
}
export interface PromptBrowserOpenContext {
createReadlineInterface?: () => PromptBrowserOpenReadlineInterface
execFile?: PromptBrowserOpenExecFile
globalInfo: (message: string) => void
globalWarn: (message: string) => void
process: PromptBrowserOpenProcess
}
export interface PromptBrowserOpenParams {
authUrl: string
context: PromptBrowserOpenContext
pollPromise: Promise<string>
}
/**
* Wraps a token-polling promise with an optional "Press ENTER to open in
* browser" prompt.
*
* While the poll runs in the background, listens for the user pressing Enter
* to open the authentication URL in their browser. When the poll completes
* (regardless of whether the user pressed Enter), the keyboard listener is
* cleaned up.
*
* Error-tolerant: failures in the keyboard listener or browser opening are
* logged as warnings and do not interrupt the poll.
*/
export async function promptBrowserOpen ({
authUrl,
context,
pollPromise,
}: PromptBrowserOpenParams): Promise<string> {
const { createReadlineInterface, execFile, globalInfo, globalWarn, process } = context
if (!createReadlineInterface || !execFile || !process.stdin.isTTY) {
return pollPromise
}
// Validate the URL before passing it to a shell command. On Windows,
// cmd.exe re-parses execFile arguments and would interpret shell
// metacharacters (&, |, etc.) in the URL as operators.
let parsedUrl: URL
try {
parsedUrl = new URL(authUrl)
} catch {
return pollPromise
}
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
return pollPromise
}
const canonicalUrl = parsedUrl.href
let cmd: string
let args: string[]
switch (process.platform) {
case 'darwin':
cmd = 'open'
args = [canonicalUrl]
break
case 'win32': {
cmd = 'cmd'
// Windows edge cases for opening URLs from Node.js:
//
// The clean approach would be calling the Win32 ShellExecuteW API
// directly, which is what native Windows programs use. However,
// ShellExecuteW is a native API, not an executable — Node.js cannot
// call it from child_process without a native addon.
//
// All process-spawning alternatives have drawbacks:
// - cmd /c start: cmd.exe re-parses args; special characters in
// URLs (&, |, ^, %, etc.) are treated as shell
// operators
// - explorer.exe: breaks on URLs with query strings (?key=value),
// opening File Explorer instead of the browser
// (https://github.com/dotnet/runtime/issues/108817)
// - url.dll: undocumented, can strip query params on Win 7+
// - PowerShell: slow startup, own escaping issues
//
// Since pnpm already ships native addons, a small Rust/N-API addon
// calling ShellExecuteW directly could replace this in the future.
//
// For now, use cmd /c start with ^ escaping for special characters.
const escapedUrl = canonicalUrl.replace(/[&|<>^%()!]/g, '^$&')
args = ['/c', 'start', '', escapedUrl]
break
}
case 'linux':
cmd = 'xdg-open'
args = [canonicalUrl]
break
default:
return pollPromise
}
let rl: PromptBrowserOpenReadlineInterface
try {
rl = createReadlineInterface()
} catch (err) {
globalWarn(`Could not set up keyboard listener: ${String(err)}`)
return pollPromise
}
globalInfo('Press ENTER to open the URL in your browser.')
rl.once('line', () => {
runExecFile(execFile, cmd, args).catch((err) => {
globalWarn(`Could not open browser automatically: ${String(err)}`)
globalInfo('Please open the URL shown above manually.')
})
})
// Only await pollPromise — do NOT await the Enter keypress.
//
// The Enter listener is a fire-and-forget side effect. Users may authenticate
// on their phone (via QR code or pasted URL) without ever pressing Enter, so
// the poll must be able to complete independently.
//
// npm uses Promise.all([opener, poll]) which blocks the entire flow until the
// user presses Enter — even if authentication already succeeded on another
// device: <https://github.com/npm/npm-profile/blob/d1a48be4/lib/index.js#L85-L98>
try {
return await pollPromise
} finally {
rl.close()
}
}
function runExecFile (
execFile: PromptBrowserOpenExecFile,
cmd: string,
args: string[]
): Promise<void> {
return new Promise<void>((resolve, reject) => {
execFile(cmd, args, (err) => {
if (err) reject(err)
else resolve()
})
})
}

View File

@@ -3,6 +3,8 @@ import { PnpmError } from '@pnpm/error'
import { generateQrCode } from './generateQrCode.js'
import type { WebAuthFetchOptions, WebAuthFetchResponse } from './pollForWebAuthToken.js'
import { pollForWebAuthToken } from './pollForWebAuthToken.js'
import type { PromptBrowserOpenExecFile, PromptBrowserOpenReadlineInterface } from './promptBrowserOpen.js'
import { promptBrowserOpen } from './promptBrowserOpen.js'
export interface OtpEnquirer {
prompt: (options: OtpPromptOptions) => Promise<OtpPromptResponse | undefined>
@@ -22,14 +24,22 @@ interface OtpDate {
now: () => number
}
export interface OtpProcess {
platform?: NodeJS.Platform
stdin: { isTTY?: boolean }
stdout: { isTTY?: boolean }
}
export interface OtpContext {
Date: OtpDate
setTimeout: (cb: () => void, ms: number) => void
createReadlineInterface?: () => PromptBrowserOpenReadlineInterface
enquirer: OtpEnquirer
execFile?: PromptBrowserOpenExecFile
fetch: (url: string, options: WebAuthFetchOptions) => Promise<WebAuthFetchResponse>
globalInfo: (message: string) => void
globalWarn: (message: string) => void
process: Record<'stdin' | 'stdout', { isTTY?: boolean }>
process: OtpProcess
}
interface OtpErrorBody {
@@ -93,11 +103,16 @@ export async function withOtpHandling<T> ({
if (error.body?.authUrl && error.body?.doneUrl) {
const qrCode = generateQrCode(error.body.authUrl)
globalInfo(`Authenticate your account at:\n${error.body.authUrl}\n\n${qrCode}`)
otp = await pollForWebAuthToken({
const pollPromise = pollForWebAuthToken({
context,
doneUrl: error.body.doneUrl,
fetchOptions,
})
otp = await promptBrowserOpen({
authUrl: error.body.authUrl,
context,
pollPromise,
})
} else {
const enquirerResponse = await enquirer.prompt({
message: 'This operation requires a one-time password.\nEnter OTP:',

View File

@@ -0,0 +1,354 @@
import { jest } from '@jest/globals'
import {
promptBrowserOpen,
type PromptBrowserOpenContext,
type PromptBrowserOpenExecFile,
type PromptBrowserOpenReadlineInterface,
} from '@pnpm/network.web-auth'
function createDeferred<T> (): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
interface MockReadlineInterface extends PromptBrowserOpenReadlineInterface {
simulateEnterKeypress: () => void
}
const createMockReadlineInterface = (): MockReadlineInterface => {
let lineListener: (() => void) | undefined
return {
once: (_event: string, listener: () => void) => {
lineListener = listener
},
close: jest.fn<() => void>(),
simulateEnterKeypress: () => lineListener?.(),
}
}
type MockContextOverrides = Omit<Partial<PromptBrowserOpenContext>, 'process'> & {
process?: Partial<PromptBrowserOpenContext['process']>
}
const createMockContext = (overrides?: MockContextOverrides): PromptBrowserOpenContext => ({
globalInfo: () => {},
globalWarn: () => {},
...overrides,
process: {
platform: 'linux',
stdin: { isTTY: true },
...overrides?.process,
},
})
describe('promptBrowserOpen', () => {
it('returns the poll result when poll completes before Enter keypress', async () => {
const mockRl = createMockReadlineInterface()
const execFile = jest.fn<PromptBrowserOpenExecFile>()
const context = createMockContext({
createReadlineInterface: () => mockRl,
execFile,
})
const token = await promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.resolve('my-token'),
})
expect(token).toBe('my-token')
expect(mockRl.close).toHaveBeenCalled()
expect(execFile).not.toHaveBeenCalled()
})
it('opens browser via execFile when Enter key is pressed before poll completes', async () => {
const mockRl = createMockReadlineInterface()
const pollDeferred = createDeferred<string>()
const execFile = jest.fn<PromptBrowserOpenExecFile>((_file, _args, cb) => {
cb(null)
})
const context = createMockContext({
createReadlineInterface: () => mockRl,
execFile,
})
const resultPromise = promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: pollDeferred.promise,
})
mockRl.simulateEnterKeypress()
await new Promise<void>(resolve => queueMicrotask(resolve))
expect(execFile).toHaveBeenCalledWith('xdg-open', ['https://example.com/auth'], expect.any(Function))
pollDeferred.resolve('token-after-enter')
const token = await resultPromise
expect(token).toBe('token-after-enter')
expect(mockRl.close).toHaveBeenCalled()
})
it('uses "open" on darwin', async () => {
const mockRl = createMockReadlineInterface()
const pollDeferred = createDeferred<string>()
const execFile = jest.fn<PromptBrowserOpenExecFile>((_file, _args, cb) => {
cb(null)
})
const context = createMockContext({
createReadlineInterface: () => mockRl,
execFile,
process: { platform: 'darwin' },
})
const resultPromise = promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: pollDeferred.promise,
})
mockRl.simulateEnterKeypress()
await new Promise<void>(resolve => queueMicrotask(resolve))
expect(execFile).toHaveBeenCalledWith('open', ['https://example.com/auth'], expect.any(Function))
pollDeferred.resolve('tok')
await resultPromise
})
it('uses "cmd /c start" on win32', async () => {
const mockRl = createMockReadlineInterface()
const pollDeferred = createDeferred<string>()
const execFile = jest.fn<PromptBrowserOpenExecFile>((_file, _args, cb) => {
cb(null)
})
const context = createMockContext({
createReadlineInterface: () => mockRl,
execFile,
process: { platform: 'win32' },
})
const resultPromise = promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: pollDeferred.promise,
})
mockRl.simulateEnterKeypress()
await new Promise<void>(resolve => queueMicrotask(resolve))
expect(execFile).toHaveBeenCalledWith('cmd', ['/c', 'start', '', 'https://example.com/auth'], expect.any(Function))
pollDeferred.resolve('tok')
await resultPromise
})
it('passes URLs with query parameters to execFile on win32', async () => {
const mockRl = createMockReadlineInterface()
const pollDeferred = createDeferred<string>()
const execFile = jest.fn<PromptBrowserOpenExecFile>((_file, _args, cb) => {
cb(null)
})
const authUrl = 'https://example.com/auth?token=abc&redirect=https%3A%2F%2Fexample.com'
const context = createMockContext({
createReadlineInterface: () => mockRl,
execFile,
process: { platform: 'win32' },
})
const resultPromise = promptBrowserOpen({
authUrl,
context,
pollPromise: pollDeferred.promise,
})
mockRl.simulateEnterKeypress()
await new Promise<void>(resolve => queueMicrotask(resolve))
// & and % are escaped with ^ for cmd.exe
expect(execFile).toHaveBeenCalledWith('cmd', ['/c', 'start', '', 'https://example.com/auth?token=abc^&redirect=https^%3A^%2F^%2Fexample.com'], expect.any(Function))
pollDeferred.resolve('tok')
await resultPromise
})
it('skips browser prompt on unsupported platform', async () => {
const execFile = jest.fn<PromptBrowserOpenExecFile>()
const context = createMockContext({
createReadlineInterface: createMockReadlineInterface,
execFile,
process: { platform: 'freebsd' },
})
const token = await promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.resolve('plain-token'),
})
expect(token).toBe('plain-token')
expect(execFile).not.toHaveBeenCalled()
})
it('skips browser prompt when platform is undefined', async () => {
const execFile = jest.fn<PromptBrowserOpenExecFile>()
const context = createMockContext({
createReadlineInterface: createMockReadlineInterface,
execFile,
process: { platform: undefined },
})
const token = await promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.resolve('plain-token'),
})
expect(token).toBe('plain-token')
expect(execFile).not.toHaveBeenCalled()
})
it('warns and continues polling when execFile fails', async () => {
const mockRl = createMockReadlineInterface()
const pollDeferred = createDeferred<string>()
const globalWarn = jest.fn<(msg: string) => void>()
const globalInfo = jest.fn<(msg: string) => void>()
const execFile = jest.fn<PromptBrowserOpenExecFile>((_file, _args, cb) => {
cb(new Error('xdg-open not found'))
})
const context = createMockContext({
createReadlineInterface: () => mockRl,
execFile,
globalInfo,
globalWarn,
})
const resultPromise = promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: pollDeferred.promise,
})
mockRl.simulateEnterKeypress()
await new Promise<void>(resolve => queueMicrotask(resolve))
await new Promise<void>(resolve => queueMicrotask(resolve))
expect(globalWarn).toHaveBeenCalledWith(expect.stringContaining('xdg-open not found'))
expect(globalInfo).toHaveBeenCalledWith('Please open the URL shown above manually.')
pollDeferred.resolve('tok')
expect(await resultPromise).toBe('tok')
})
it('warns and falls back to plain poll when createReadlineInterface throws', async () => {
const globalWarn = jest.fn<(msg: string) => void>()
const execFile = jest.fn<PromptBrowserOpenExecFile>()
const context = createMockContext({
createReadlineInterface: () => {
throw new Error('setRawMode not supported')
},
execFile,
globalWarn,
})
const token = await promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.resolve('fallback-token'),
})
expect(token).toBe('fallback-token')
expect(globalWarn).toHaveBeenCalledWith(expect.stringContaining('setRawMode not supported'))
expect(execFile).not.toHaveBeenCalled()
})
it('falls back to plain poll when createReadlineInterface is not provided', async () => {
const context = createMockContext({
execFile: jest.fn<PromptBrowserOpenExecFile>(),
})
const token = await promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.resolve('plain-token'),
})
expect(token).toBe('plain-token')
})
it('falls back to plain poll when execFile is not provided', async () => {
const context = createMockContext({
createReadlineInterface: createMockReadlineInterface,
})
const token = await promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.resolve('plain-token'),
})
expect(token).toBe('plain-token')
})
it('falls back to plain poll when stdin is not a TTY', async () => {
const context = createMockContext({
createReadlineInterface: createMockReadlineInterface,
execFile: jest.fn<PromptBrowserOpenExecFile>(),
process: { stdin: { isTTY: false } },
})
const token = await promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.resolve('plain-token'),
})
expect(token).toBe('plain-token')
})
it('shows the press-Enter message', async () => {
const mockRl = createMockReadlineInterface()
const globalInfo = jest.fn<(msg: string) => void>()
const context = createMockContext({
createReadlineInterface: () => mockRl,
execFile: jest.fn<PromptBrowserOpenExecFile>(),
globalInfo,
})
await promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.resolve('tok'),
})
expect(globalInfo).toHaveBeenCalledWith('Press ENTER to open the URL in your browser.')
})
it('cleans up when poll rejects', async () => {
const mockRl = createMockReadlineInterface()
const context = createMockContext({
createReadlineInterface: () => mockRl,
execFile: jest.fn<PromptBrowserOpenExecFile>(),
})
await expect(promptBrowserOpen({
authUrl: 'https://example.com/auth',
context,
pollPromise: Promise.reject(new Error('timeout')),
})).rejects.toThrow('timeout')
expect(mockRl.close).toHaveBeenCalled()
})
})

View File

@@ -33,7 +33,11 @@ function createMockResponse (init: {
}
}
const createOtpMockContext = (overrides?: Partial<OtpContext>): OtpContext => ({
type MockContextOverrides = Omit<Partial<OtpContext>, 'process'> & {
process?: Partial<OtpContext['process']>
}
const createOtpMockContext = (overrides?: MockContextOverrides): OtpContext => ({
Date: { now: () => 0 },
setTimeout: (cb: () => void) => cb(),
enquirer: { prompt: async () => ({ otp: '123456' }) },
@@ -47,11 +51,12 @@ const createOtpMockContext = (overrides?: Partial<OtpContext>): OtpContext => ({
globalWarn: msg => {
throw new Error(`Unexpected call to globalWarn: ${msg}`)
},
...overrides,
process: {
stdin: { isTTY: true },
stdout: { isTTY: true },
...overrides?.process,
},
...overrides,
})
const fetchOptions: WebAuthFetchOptions = { method: 'GET' }
@@ -74,10 +79,7 @@ describe('withOtpHandling', () => {
it('throws OtpNonInteractiveError when terminal is not interactive', async () => {
const context = createOtpMockContext({
process: {
stdin: { isTTY: false },
stdout: { isTTY: true },
},
process: { stdin: { isTTY: false } },
})
const operation = async () => {
throw Object.assign(new Error('otp'), { code: 'EOTP' })
@@ -88,10 +90,7 @@ describe('withOtpHandling', () => {
it('throws OtpNonInteractiveError when stdout is not interactive', async () => {
const context = createOtpMockContext({
process: {
stdin: { isTTY: true },
stdout: { isTTY: false },
},
process: { stdout: { isTTY: false } },
})
const operation = async () => {
throw Object.assign(new Error('otp'), { code: 'EOTP' })

View File

@@ -1,3 +1,6 @@
import { execFile } from 'node:child_process'
import readline from 'node:readline'
import { globalInfo, globalWarn } from '@pnpm/logger'
import { fetch } from '@pnpm/network.fetch'
import type { ExportedManifest } from '@pnpm/releasing.exportable-manifest'
@@ -28,8 +31,10 @@ type SharedContext =
export const SHARED_CONTEXT: SharedContext = {
Date,
createReadlineInterface: readline.createInterface.bind(null, { input: process.stdin }),
ciInfo,
enquirer,
execFile,
fetch,
globalInfo,
globalWarn,

View File

@@ -16,7 +16,11 @@ function createOkResponse (): OtpPublishResponse {
return { ok: true, status: 200, statusText: 'OK', text: async () => '' }
}
function createMockContext (overrides?: Partial<OtpContext>): OtpContext {
type MockContextOverrides = Omit<Partial<OtpContext>, 'process'> & {
process?: Partial<OtpContext['process']>
}
function createMockContext (overrides?: MockContextOverrides): OtpContext {
return {
Date: { now: () => 0 },
setTimeout: (cb: () => void) => cb(),
@@ -33,9 +37,13 @@ function createMockContext (overrides?: Partial<OtpContext>): OtpContext {
globalWarn: msg => {
throw new Error(`Unexpected call to globalWarn: ${msg}`)
},
process: { stdin: { isTTY: true }, stdout: { isTTY: true } },
publish: async () => createOkResponse(),
...overrides,
process: {
stdin: { isTTY: true },
stdout: { isTTY: true },
...overrides?.process,
},
}
}
@@ -66,7 +74,7 @@ describe('publishWithOtpHandling', () => {
it('throws OtpNonInteractiveError when terminal is not interactive', async () => {
const context = createMockContext({
process: { stdin: { isTTY: false }, stdout: { isTTY: true } },
process: { stdin: { isTTY: false } },
publish: async () => {
throw Object.assign(new Error('otp'), { code: 'EOTP' })
},