diff --git a/.changeset/browser-open-on-enter.md b/.changeset/browser-open-on-enter.md new file mode 100644 index 0000000000..f60d5eb245 --- /dev/null +++ b/.changeset/browser-open-on-enter.md @@ -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. diff --git a/auth/commands/src/login.ts b/auth/commands/src/login.ts index 634eab0320..3e1543c76c 100644 --- a/auth/commands/src/login.ts +++ b/auth/commands/src/login.ts @@ -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 globalInfo: (message: string) => void globalWarn: (message: string) => void - process: Record<'stdin' | 'stdout', { isTTY?: boolean }> + process: LoginProcess readIniFile: (configPath: string) => Promise writeIniFile: (configPath: string, settings: Record) => Promise } @@ -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 + context: Pick 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 + context: Pick fetchOptions: WebAuthFetchOptions registry: string } diff --git a/auth/commands/test/login.test.ts b/auth/commands/test/login.test.ts index bb6de3ccc6..b9d67e9b08 100644 --- a/auth/commands/test/login.test.ts +++ b/auth/commands/test/login.test.ts @@ -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 => ({ +type MockContextOverrides = Omit, 'process'> & { + process?: Partial +} + +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.'], + ]) }) }) diff --git a/network/web-auth/src/index.ts b/network/web-auth/src/index.ts index 668a716fcd..2544b5f11e 100644 --- a/network/web-auth/src/index.ts +++ b/network/web-auth/src/index.ts @@ -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, diff --git a/network/web-auth/src/promptBrowserOpen.ts b/network/web-auth/src/promptBrowserOpen.ts new file mode 100644 index 0000000000..e42fd0e308 --- /dev/null +++ b/network/web-auth/src/promptBrowserOpen.ts @@ -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 +} + +/** + * 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 { + 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: + try { + return await pollPromise + } finally { + rl.close() + } +} + +function runExecFile ( + execFile: PromptBrowserOpenExecFile, + cmd: string, + args: string[] +): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, (err) => { + if (err) reject(err) + else resolve() + }) + }) +} diff --git a/network/web-auth/src/withOtpHandling.ts b/network/web-auth/src/withOtpHandling.ts index 60c086fb3c..dca126ab72 100644 --- a/network/web-auth/src/withOtpHandling.ts +++ b/network/web-auth/src/withOtpHandling.ts @@ -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 @@ -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 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 ({ 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:', diff --git a/network/web-auth/test/promptBrowserOpen.test.ts b/network/web-auth/test/promptBrowserOpen.test.ts new file mode 100644 index 0000000000..cbc6a17127 --- /dev/null +++ b/network/web-auth/test/promptBrowserOpen.test.ts @@ -0,0 +1,354 @@ +import { jest } from '@jest/globals' +import { + promptBrowserOpen, + type PromptBrowserOpenContext, + type PromptBrowserOpenExecFile, + type PromptBrowserOpenReadlineInterface, +} from '@pnpm/network.web-auth' + +function createDeferred (): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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, 'process'> & { + process?: Partial +} + +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() + 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() + const execFile = jest.fn((_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(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() + const execFile = jest.fn((_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(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() + const execFile = jest.fn((_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(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() + const execFile = jest.fn((_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(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() + 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() + 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() + const globalWarn = jest.fn<(msg: string) => void>() + const globalInfo = jest.fn<(msg: string) => void>() + const execFile = jest.fn((_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(resolve => queueMicrotask(resolve)) + await new Promise(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() + 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(), + }) + + 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(), + 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(), + 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(), + }) + + await expect(promptBrowserOpen({ + authUrl: 'https://example.com/auth', + context, + pollPromise: Promise.reject(new Error('timeout')), + })).rejects.toThrow('timeout') + + expect(mockRl.close).toHaveBeenCalled() + }) +}) diff --git a/network/web-auth/test/withOtpHandling.test.ts b/network/web-auth/test/withOtpHandling.test.ts index 454ed63cca..257c9e3b7f 100644 --- a/network/web-auth/test/withOtpHandling.test.ts +++ b/network/web-auth/test/withOtpHandling.test.ts @@ -33,7 +33,11 @@ function createMockResponse (init: { } } -const createOtpMockContext = (overrides?: Partial): OtpContext => ({ +type MockContextOverrides = Omit, 'process'> & { + process?: Partial +} + +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 => ({ 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' }) diff --git a/releasing/commands/src/publish/utils/shared-context.ts b/releasing/commands/src/publish/utils/shared-context.ts index ea7e97656b..cab438b1cf 100644 --- a/releasing/commands/src/publish/utils/shared-context.ts +++ b/releasing/commands/src/publish/utils/shared-context.ts @@ -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, diff --git a/releasing/commands/test/publish/otp.test.ts b/releasing/commands/test/publish/otp.test.ts index 5252db45ea..59dd0d29c1 100644 --- a/releasing/commands/test/publish/otp.test.ts +++ b/releasing/commands/test/publish/otp.test.ts @@ -16,7 +16,11 @@ function createOkResponse (): OtpPublishResponse { return { ok: true, status: 200, statusText: 'OK', text: async () => '' } } -function createMockContext (overrides?: Partial): OtpContext { +type MockContextOverrides = Omit, 'process'> & { + process?: Partial +} + +function createMockContext (overrides?: MockContextOverrides): OtpContext { return { Date: { now: () => 0 }, setTimeout: (cb: () => void) => cb(), @@ -33,9 +37,13 @@ function createMockContext (overrides?: Partial): 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' }) },