refactor: replace enquirer with @inquirer/prompts (#11942)

Replaces the unmaintained `enquirer` package with `@inquirer/prompts` for all interactive CLI prompts. Fixes the `update -i` scrolling overflow bug where long choice lists were clipped in the terminal.

Fixes #6643

## User-facing changes

- **`pnpm update -i` / `pnpm update -i --latest`**: Scrolling now works correctly when many packages are available; the new library uses visual-line-aware pagination via `usePagination`
- **`pnpm audit --fix -i`**: Same scrolling fix for vulnerability selection
- **`pnpm approve-builds`**: Interactive build approval prompts updated
- **`pnpm patch`**: Version selection and "apply to all" prompts updated
- **`pnpm patch-remove`**: Patch removal selection updated
- **`pnpm publish`**: Branch confirmation prompt updated
- **`pnpm login`**: Credential prompts updated
- **`pnpm run` / `pnpm exec`** (with `verifyDepsBeforeRun=prompt`): Confirmation prompt updated

## Internal changes

- `OtpEnquirer` DI interface changed from `{ prompt }` to `{ input }`
- `LoginEnquirer` DI interface changed from `{ prompt }` to `{ input, password }`
- `enquirer` removed from catalog and all 8 package.json files
- `@inquirer/prompts` v8.4.3 added to catalog and all 8 package.json files
- Removed `OtpPromptOptions` and `OtpPromptResponse` exports from `@pnpm/network.web-auth` (no longer needed)

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Abdullah Alaqeel
2026-05-28 18:53:52 +03:00
committed by GitHub
parent 39101f5e37
commit 2cadfb5d3d
41 changed files with 1033 additions and 696 deletions

View File

@@ -0,0 +1,29 @@
---
"@pnpm/auth.commands": minor
"@pnpm/building.commands": minor
"@pnpm/deps.compliance.commands": minor
"@pnpm/exec.commands": minor
"@pnpm/installing.commands": minor
"@pnpm/installing.deps-installer": minor
"@pnpm/network.web-auth": minor
"@pnpm/patching.commands": minor
"@pnpm/registry-access.commands": minor
"@pnpm/releasing.commands": minor
"pnpm": minor
---
Replaced `enquirer` with `@inquirer/prompts` for all interactive prompts. Fixes the `update -i` scrolling overflow bug where long choice lists were clipped in the terminal [#6643](https://github.com/pnpm/pnpm/issues/6643).
**User-facing changes:**
- `pnpm update -i` / `pnpm update -i --latest`: Scrolling now works correctly when many packages are available; the new library uses visual-line-aware pagination via `usePagination`
- `pnpm audit --fix -i`: Same scrolling fix for vulnerability selection
- `pnpm approve-builds`: Interactive build approval prompts updated
- `pnpm patch`: Version selection and "apply to all" prompts updated
- `pnpm patch-remove`: Patch removal selection updated
- `pnpm publish`: Branch confirmation prompt updated
- `pnpm login`: Credential prompts updated
- `pnpm run` / `pnpm exec` (with `verifyDepsBeforeRun=prompt`): Confirmation prompt updated
Vim-style `j`/`k` keys still work for up/down navigation in all interactive prompts.
**Internal:** The `OtpEnquirer` and `LoginEnquirer` DI interfaces changed from `{ prompt }` to `{ input }` / `{ input, password }` respectively. Plugins or custom builds that inject their own enquirer mock will need to update.

View File

@@ -32,13 +32,13 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/network.fetch": "workspace:*",
"@pnpm/network.web-auth": "workspace:*",
"@pnpm/registry-access.client": "workspace:*",
"enquirer": "catalog:",
"normalize-registry-url": "catalog:",
"read-ini-file": "catalog:",
"render-help": "catalog:",

View File

@@ -1,6 +1,7 @@
import path from 'node:path'
import readline from 'node:readline'
import { input, password as passwordPrompt } from '@inquirer/prompts'
import { docsUrl } from '@pnpm/cli.utils'
import { type Config, types as allTypes } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
@@ -16,7 +17,6 @@ import {
withOtpHandling,
} from '@pnpm/network.web-auth'
import { addUser, AddUserHttpError, AddUserNoTokenError } from '@pnpm/registry-access.client'
import enquirer from 'enquirer'
import normalizeRegistryUrl from 'normalize-registry-url'
import { readIniFile } from 'read-ini-file'
import { renderHelp } from 'render-help'
@@ -87,13 +87,8 @@ export interface LoginDate {
}
export interface LoginEnquirer {
prompt: (options: LoginEnquirerOptions) => Promise<Record<string, string>>
}
export interface LoginEnquirerOptions {
message: string
name: string
type: string
input: (options: { message: string }) => Promise<string>
password: (options: { message: string }) => Promise<string>
}
export interface LoginFetchResponse {
@@ -145,7 +140,7 @@ export const DEFAULT_CONTEXT: LoginContext = {
Date,
setTimeout,
createReadlineInterface: readline.createInterface.bind(null, { input: process.stdin }),
enquirer,
enquirer: { input, password: passwordPrompt },
fetch,
globalInfo,
globalWarn,
@@ -285,21 +280,19 @@ async function classicLogin ({
}: ClassicLoginParams): Promise<string> {
const { enquirer, fetch, globalInfo, globalWarn } = context
const { username } = await enquirer.prompt({
message: 'Username:',
name: 'username',
type: 'input',
})
const { password } = await enquirer.prompt({
message: 'Password:',
name: 'password',
type: 'password',
})
const { email } = await enquirer.prompt({
message: 'Email (this IS public):',
name: 'email',
type: 'input',
})
let username: string
let password: string
let email: string
try {
username = await enquirer.input({ message: 'Username:' })
password = await enquirer.password({ message: 'Password:' })
email = await enquirer.input({ message: 'Email (this IS public):' })
} catch (err: unknown) {
if (err instanceof Error && err.name === 'ExitPromptError') {
throw new PnpmError('LOGIN_CANCELED', 'Login canceled')
}
throw err
}
if (!username || !password || !email) {
throw new LoginMissingCredentialsError()

View File

@@ -13,8 +13,10 @@ const TEST_CONTEXT: LoginContext = {
once: () => {},
close: () => {},
}),
enquirer: { prompt: async () => {
throw new Error('Unexpected call to enquirer.prompt')
enquirer: { input: async () => {
throw new Error('Unexpected call to enquirer.input')
}, password: async () => {
throw new Error('Unexpected call to enquirer.password')
} },
fetch: async url => {
throw new Error(`Unexpected call to fetch: ${url}`)
@@ -269,11 +271,14 @@ describe('login', () => {
throw new Error(`Unexpected call to fetch: ${url}`)
},
enquirer: {
prompt: async (opts: { message: string, name: string, type: string }): Promise<Record<string, string>> => {
if (opts.name === 'username') return { username: 'john' }
if (opts.name === 'password') return { password: 'secret' }
if (opts.name === 'email') return { email: 'john@example.com' }
throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`)
input: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Username:') return 'john'
if (opts.message === 'Email (this IS public):') return 'john@example.com'
throw new Error(`Unexpected call to enquirer.input: ${opts.message}`)
},
password: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Password:') return 'secret'
throw new Error(`Unexpected call to enquirer.password: ${opts.message}`)
},
},
})
@@ -325,12 +330,15 @@ describe('login', () => {
throw new Error(`Unexpected call to fetch: ${url}`)
},
enquirer: {
prompt: async (opts: { message: string, name: string, type: string }): Promise<Record<string, string>> => {
if (opts.name === 'username') return { username: 'alice' }
if (opts.name === 'password') return { password: 'pass' }
if (opts.name === 'email') return { email: 'alice@example.com' }
if (opts.name === 'otp') return { otp: '999999' }
throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`)
input: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Username:') return 'alice'
if (opts.message === 'Email (this IS public):') return 'alice@example.com'
if (opts.message === 'This operation requires a one-time password.\nEnter OTP:') return '999999'
throw new Error(`Unexpected call to enquirer.input: ${opts.message}`)
},
password: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Password:') return 'pass'
throw new Error(`Unexpected call to enquirer.password: ${opts.message}`)
},
},
})
@@ -388,11 +396,14 @@ describe('login', () => {
throw new Error(`Unexpected call to fetch: ${url}`)
},
enquirer: {
prompt: async (opts: { message: string, name: string, type: string }): Promise<Record<string, string>> => {
if (opts.name === 'username') return { username: 'bob' }
if (opts.name === 'password') return { password: 'pass' }
if (opts.name === 'email') return { email: 'bob@example.com' }
throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`)
input: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Username:') return 'bob'
if (opts.message === 'Email (this IS public):') return 'bob@example.com'
throw new Error(`Unexpected call to enquirer.input: ${opts.message}`)
},
password: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Password:') return 'pass'
throw new Error(`Unexpected call to enquirer.password: ${opts.message}`)
},
},
})
@@ -424,15 +435,18 @@ describe('login', () => {
})
},
enquirer: {
prompt: async (opts: { message: string, name: string, type: string }): Promise<Record<string, string>> => {
if (opts.name === 'username') return { username: 'alice' }
if (opts.name === 'password') return { password: 'pass' }
if (opts.name === 'email') return { email: 'alice@example.com' }
throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`)
input: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Username:') return 'alice'
if (opts.message === 'Email (this IS public):') return 'alice@example.com'
throw new Error(`Unexpected call to enquirer.input: ${opts.message}`)
},
password: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Password:') return 'pass'
throw new Error(`Unexpected call to enquirer.password: ${opts.message}`)
},
},
})
const opts = { configDir: '/otp/config', dir: '/mock', authConfig: {}, registry: 'https://example.org' }
const opts = { configDir: '/mock/config', dir: '/mock', authConfig: {}, registry: 'https://example.org' }
const promise = login({ context, opts })
await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_LOGIN_FAILED')
await expect(promise).rejects.toHaveProperty(['message'], 'Login failed (HTTP 403): Forbidden')
@@ -453,11 +467,14 @@ describe('login', () => {
throw new Error(`Unexpected call to fetch: ${url}`)
},
enquirer: {
prompt: async (opts: { message: string, name: string, type: string }): Promise<Record<string, string>> => {
if (opts.name === 'username') return { username: '' }
if (opts.name === 'password') return { password: 'pass' }
if (opts.name === 'email') return { email: 'a@b.com' }
throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`)
input: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Username:') return ''
if (opts.message === 'Email (this IS public):') return 'a@b.com'
throw new Error(`Unexpected call to enquirer.input: ${opts.message}`)
},
password: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Password:') return 'pass'
throw new Error(`Unexpected call to enquirer.password: ${opts.message}`)
},
},
})
@@ -489,11 +506,14 @@ describe('login', () => {
throw new Error(`Unexpected call to fetch: ${url}`)
},
enquirer: {
prompt: async (opts: { message: string, name: string, type: string }): Promise<Record<string, string>> => {
if (opts.name === 'username') return { username: 'alice' }
if (opts.name === 'password') return { password: 'pass' }
if (opts.name === 'email') return { email: 'alice@example.com' }
throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`)
input: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Username:') return 'alice'
if (opts.message === 'Email (this IS public):') return 'alice@example.com'
throw new Error(`Unexpected call to enquirer.input: ${opts.message}`)
},
password: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Password:') return 'pass'
throw new Error(`Unexpected call to enquirer.password: ${opts.message}`)
},
},
})
@@ -551,11 +571,14 @@ describe('login', () => {
throw new Error(`Unexpected call to fetch: ${url}`)
},
enquirer: {
prompt: async (opts: { message: string, name: string, type: string }): Promise<Record<string, string>> => {
if (opts.name === 'username') return { username: 'jane' }
if (opts.name === 'password') return { password: 'pass' }
if (opts.name === 'email') return { email: 'jane@example.com' }
throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`)
input: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Username:') return 'jane'
if (opts.message === 'Email (this IS public):') return 'jane@example.com'
throw new Error(`Unexpected call to enquirer.input: ${opts.message}`)
},
password: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Password:') return 'pass'
throw new Error(`Unexpected call to enquirer.password: ${opts.message}`)
},
},
})
@@ -589,11 +612,14 @@ describe('login', () => {
})
},
enquirer: {
prompt: async (opts: { message: string, name: string, type: string }): Promise<Record<string, string>> => {
if (opts.name === 'username') return { username: 'alice' }
if (opts.name === 'password') return { password: 'pass' }
if (opts.name === 'email') return { email: 'alice@example.com' }
throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`)
input: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Username:') return 'alice'
if (opts.message === 'Email (this IS public):') return 'alice@example.com'
throw new Error(`Unexpected call to enquirer.input: ${opts.message}`)
},
password: async (opts: { message: string }): Promise<string> => {
if (opts.message === 'Password:') return 'pass'
throw new Error(`Unexpected call to enquirer.password: ${opts.message}`)
},
},
})

View File

@@ -32,6 +32,7 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/building.after-install": "workspace:*",
"@pnpm/cli.command": "workspace:*",
"@pnpm/cli.common-cli-options-help": "workspace:*",
@@ -48,7 +49,6 @@
"@pnpm/util.lex-comparator": "catalog:",
"@pnpm/workspace.projects-sorter": "workspace:*",
"chalk": "catalog:",
"enquirer": "catalog:",
"p-limit": "catalog:",
"ramda": "catalog:",
"render-help": "catalog:"

View File

@@ -1,3 +1,4 @@
import { checkbox, confirm } from '@inquirer/prompts'
import type { CommandHandlerMap } from '@pnpm/cli.command'
import type { Config, ConfigContext } from '@pnpm/config.reader'
import { writeSettings } from '@pnpm/config.writer'
@@ -8,7 +9,6 @@ import { type StrictModules, writeModulesManifest } from '@pnpm/installing.modul
import { globalInfo } from '@pnpm/logger'
import { lexCompare } from '@pnpm/util.lex-comparator'
import chalk from 'chalk'
import enquirer from 'enquirer'
import { renderHelp } from 'render-help'
import { rebuild, type RebuildCommandOpts } from '../build/index.js'
@@ -121,43 +121,32 @@ export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOp
} else if (opts.all) {
buildPackages = sortUniqueStrings([...automaticallyIgnoredBuilds])
} else {
const { result } = await enquirer.prompt({
choices: sortUniqueStrings([...automaticallyIgnoredBuilds]),
indicator (state: any, choice: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
return ` ${choice.enabled ? '●' : '○'}`
},
message: 'Choose which packages to build ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'result',
pointer: '',
result () {
return this.selected
},
styles: {
dark: chalk.reset,
em: chalk.bgBlack.whiteBright,
success: chalk.reset,
},
type: 'multiselect',
// For Vim users (related: https://github.com/enquirer/enquirer/pull/163)
j () {
return this.down()
},
k () {
return this.up()
},
cancel () {
// By default, canceling the prompt via Ctrl+c throws an empty string.
// The custom cancel function prevents that behavior.
// Otherwise, pnpm CLI would print an error and confuse users.
// See related issue: https://github.com/enquirer/enquirer/issues/225
try {
const buildPackagesValues = await checkbox({
choices: sortUniqueStrings([...automaticallyIgnoredBuilds]).map((name) => ({
name,
value: name,
})),
message: 'Choose which packages to build ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
required: false,
theme: {
icon: { checked: '●', unchecked: '○', cursor: '' },
style: {
highlight: chalk.bgBlack.whiteBright,
},
keybindings: ['vim'],
},
})
buildPackages = buildPackagesValues
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') {
process.exit(0)
},
} as any) as any // eslint-disable-line @typescript-eslint/no-explicit-any
buildPackages = result.map(({ value }: { value: string }) => value)
}
throw err
}
}
const allowBuilds: Record<string, boolean | string> = { ...opts.allowBuilds }
if (params.length) {
@@ -178,14 +167,19 @@ export async function handler (opts: ApproveBuildsCommandOpts & RebuildCommandOp
}
if (!opts.all && !params.length) {
if (buildPackages.length) {
const confirmed = await enquirer.prompt<{ build: boolean }>({
type: 'confirm',
name: 'build',
message: `The next packages will now be built: ${buildPackages.join(', ')}.
Do you approve?`,
initial: false,
})
if (!confirmed.build) {
let isConfirmed: boolean
try {
isConfirmed = await confirm({
message: `The next packages will now be built: ${buildPackages.join(', ')}.\nDo you approve?`,
default: false,
})
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') {
process.exit(0)
}
throw err
}
if (!isConfirmed) {
return
}
} else {

View File

@@ -14,11 +14,28 @@ import { readYamlFileSync } from 'read-yaml-file'
import { writePackageSync } from 'write-package'
import { writeYamlFileSync } from 'write-yaml-file'
jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } }))
const { default: enquirer } = await import('enquirer')
jest.unstable_mockModule('@inquirer/prompts', () => {
class Separator {
separator: string
readonly type = 'separator' as const
constructor (separator: string) {
this.separator = separator
}
}
return {
Separator,
checkbox: jest.fn(),
confirm: jest.fn(),
input: jest.fn(),
password: jest.fn(),
select: jest.fn(),
}
})
const { checkbox, confirm } = await import('@inquirer/prompts')
const { approveBuilds } = await import('@pnpm/building.commands')
const prompt = jest.mocked(enquirer.prompt)
const mockCheckbox = jest.mocked(checkbox)
const mockConfirm = jest.mocked(confirm)
const REGISTRY = `http://localhost:${REGISTRY_MOCK_PORT}/`
const pnpmBin = path.join(import.meta.dirname, '../../../../pnpm/bin/pnpm.mjs')
@@ -61,16 +78,8 @@ async function approveSomeBuilds (opts?: ApproveBuildsOptions) {
await execPnpmInstall()
const config = await getApproveBuildsConfig()
prompt.mockResolvedValueOnce({
result: [
{
value: '@pnpm.e2e/pre-and-postinstall-scripts-example',
},
],
})
prompt.mockResolvedValueOnce({
build: true,
})
mockCheckbox.mockResolvedValueOnce(['@pnpm.e2e/pre-and-postinstall-scripts-example'])
mockConfirm.mockResolvedValueOnce(true)
await approveBuilds.handler({ ...config, ...opts }, [], {})
}
@@ -79,9 +88,7 @@ async function approveNoBuilds (opts?: ApproveBuildsOptions) {
await execPnpmInstall()
const config = await getApproveBuildsConfig()
prompt.mockResolvedValueOnce({
result: [],
})
mockCheckbox.mockResolvedValueOnce([])
await approveBuilds.handler({ ...config, ...opts }, [], {})
}
@@ -153,10 +160,8 @@ test("works when root project manifest doesn't exist in a workspace", async () =
writeYamlFileSync(workspaceManifestFile, { packages: ['packages/*'] })
const config = await getApproveBuildsConfig()
prompt.mockResolvedValueOnce({
result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }],
})
prompt.mockResolvedValueOnce({ build: true })
mockCheckbox.mockResolvedValueOnce(['@pnpm.e2e/pre-and-postinstall-scripts-example'])
mockConfirm.mockResolvedValueOnce(true)
await approveBuilds.handler({ ...config, workspaceDir, rootProjectManifestDir: workspaceDir }, [], {})
expect(readYamlFileSync(workspaceManifestFile)).toStrictEqual({
@@ -204,10 +209,12 @@ test('approve all builds with --all flag', async () => {
await execPnpmInstall()
const config = await getApproveBuildsConfig()
prompt.mockClear()
mockCheckbox.mockClear()
mockConfirm.mockClear()
await approveBuilds.handler({ ...config, all: true }, [], {})
expect(prompt).not.toHaveBeenCalled()
expect(mockCheckbox).not.toHaveBeenCalled()
expect(mockConfirm).not.toHaveBeenCalled()
const workspaceManifest = readYamlFileSync<any>(path.resolve('pnpm-workspace.yaml')) // eslint-disable-line
expect(workspaceManifest.allowBuilds).toStrictEqual({
@@ -231,10 +238,12 @@ test('approve builds via positional arguments', async () => {
await execPnpmInstall()
const config = await getApproveBuildsConfig()
prompt.mockClear()
mockCheckbox.mockClear()
mockConfirm.mockClear()
await approveBuilds.handler(config, ['@pnpm.e2e/pre-and-postinstall-scripts-example'], {})
expect(prompt).not.toHaveBeenCalled()
expect(mockCheckbox).not.toHaveBeenCalled()
expect(mockConfirm).not.toHaveBeenCalled()
const workspaceManifest = readYamlFileSync<any>(path.resolve('pnpm-workspace.yaml')) // eslint-disable-line
expect(workspaceManifest.allowBuilds).toStrictEqual({
@@ -261,13 +270,15 @@ test('deny builds via !pkg positional arguments', async () => {
await execPnpmInstall()
const config = await getApproveBuildsConfig()
prompt.mockClear()
mockCheckbox.mockClear()
mockConfirm.mockClear()
await approveBuilds.handler(config, [
'@pnpm.e2e/pre-and-postinstall-scripts-example',
'!@pnpm.e2e/install-script-example',
], {})
expect(prompt).not.toHaveBeenCalled()
expect(mockCheckbox).not.toHaveBeenCalled()
expect(mockConfirm).not.toHaveBeenCalled()
const workspaceManifest = readYamlFileSync<any>(path.resolve('pnpm-workspace.yaml')) // eslint-disable-line
expect(workspaceManifest.allowBuilds).toStrictEqual({
@@ -290,12 +301,14 @@ test('deny-only via !pkg keeps other builds pending', async () => {
await execPnpmInstall()
const config = await getApproveBuildsConfig()
prompt.mockClear()
mockCheckbox.mockClear()
mockConfirm.mockClear()
await approveBuilds.handler(config, [
'!@pnpm.e2e/install-script-example',
], {})
expect(prompt).not.toHaveBeenCalled()
expect(mockCheckbox).not.toHaveBeenCalled()
expect(mockConfirm).not.toHaveBeenCalled()
const workspaceManifest = readYamlFileSync<any>(path.resolve('pnpm-workspace.yaml')) // eslint-disable-line
expect(workspaceManifest.allowBuilds).toStrictEqual({
@@ -439,10 +452,8 @@ test('should retain existing allowBuilds entries when approving builds', async (
})
const config = await getApproveBuildsConfig()
prompt.mockResolvedValueOnce({
result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }],
})
prompt.mockResolvedValueOnce({ build: true })
mockCheckbox.mockResolvedValueOnce(['@pnpm.e2e/pre-and-postinstall-scripts-example'])
mockConfirm.mockResolvedValueOnce(true)
await approveBuilds.handler({
...config,
workspaceDir: temp,
@@ -492,10 +503,8 @@ test('GVS approve-builds writes settings to globalPkgDir without scanning siblin
await execPnpmInstall({ enableGlobalVirtualStore: true })
const config = await getApproveBuildsConfig()
prompt.mockResolvedValueOnce({
result: [{ value: '@pnpm.e2e/pre-and-postinstall-scripts-example' }],
})
prompt.mockResolvedValueOnce({ build: true })
mockCheckbox.mockResolvedValueOnce(['@pnpm.e2e/pre-and-postinstall-scripts-example'])
mockConfirm.mockResolvedValueOnce(true)
// Match the global-install call site: settingsDir points at the global
// packages dir (for writeSettings) but workspaceDir is not set, so install

View File

@@ -1,6 +1,7 @@
import { packageManager } from '@pnpm/cli.meta'
export * from './packageIsInstallable.js'
export * from './promptPageSize.js'
export * from './readDepNameCompletions.js'
export * from './readProjectManifest.js'
export * from './recursiveSummary.js'

View File

@@ -0,0 +1,4 @@
export function interactivePromptPageSize (): number {
const availableRows = process.stdout.rows
return availableRows == null ? 7 : Math.max(7, availableRows - 6)
}

View File

@@ -35,6 +35,7 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/cli.command": "workspace:*",
"@pnpm/cli.common-cli-options-help": "workspace:*",
"@pnpm/cli.meta": "workspace:*",
@@ -61,7 +62,6 @@
"@pnpm/workspace.project-manifest-reader": "workspace:*",
"@zkochan/table": "catalog:",
"chalk": "catalog:",
"enquirer": "catalog:",
"memoize": "catalog:",
"ramda": "catalog:",
"render-help": "catalog:",

View File

@@ -1,4 +1,5 @@
import { docsUrl, TABLE_OPTIONS } from '@pnpm/cli.utils'
import { checkbox, Separator } from '@inquirer/prompts'
import { docsUrl, interactivePromptPageSize, TABLE_OPTIONS } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, types as allTypes, type UniversalOptions } from '@pnpm/config.reader'
import { audit, type AuditAdvisory, type AuditLevelNumber, type AuditLevelString, type AuditReport, type AuditVulnerabilityCounts, type IgnoredAuditVulnerabilityCounts, normalizeGhsaId } from '@pnpm/deps.compliance.audit'
import { PnpmError } from '@pnpm/error'
@@ -8,14 +9,13 @@ import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import type { Registries } from '@pnpm/types'
import { table } from '@zkochan/table'
import chalk, { type ChalkInstance } from 'chalk'
import enquirer from 'enquirer'
import { pick, pickBy } from 'ramda'
import { renderHelp } from 'render-help'
import { createAuditNetworkOptions, loadAuditContext } from './auditContext.js'
import { fix } from './fix.js'
import { fixWithUpdate, type FixWithUpdateResult } from './fixWithUpdate.js'
import { type AuditChoiceRow, getAuditFixChoices } from './getAuditFixChoices.js'
import { getAuditFixChoices } from './getAuditFixChoices.js'
import { ignore } from './ignore.js'
import { auditSignatures } from './signatures.js'
@@ -463,71 +463,67 @@ function filterAdvisoriesForFix (
}
async function interactiveAuditFix (auditReport: AuditReport): Promise<AuditReport> {
const choices = getAuditFixChoices(Object.values(auditReport.advisories))
if (choices.length === 0) {
const choiceGroups = getAuditFixChoices(Object.values(auditReport.advisories))
if (choiceGroups.length === 0) {
return auditReport
}
const { selectedVulnerabilities } = await enquirer.prompt({
choices,
footer: '\nEnter to start fixing. Ctrl-c to cancel.',
indicator (state: any, choice: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
return ` ${choice.enabled ? '●' : '○'}`
},
message: 'Choose which vulnerabilities to fix ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'selectedVulnerabilities',
pointer: '',
result () {
return this.selected
},
format () {
if (!this.state.submitted || this.state.cancelled) return ''
if (Array.isArray(this.selected)) {
return this.selected
.filter((choice: AuditChoiceRow) => !/^\[.+\]$/.test(choice.name))
.map((choice: AuditChoiceRow) => this.styles.primary(choice.name)).join(', ')
const flatChoices: Array<Separator | { name: string; value: string; disabled?: boolean | string }> = []
for (const group of choiceGroups) {
flatChoices.push(new Separator(chalk.bold(`── ${group.message} ──`)))
for (const choice of group.choices) {
if (choice.disabled) {
flatChoices.push(new Separator(` ${choice.message ?? choice.name}`))
} else {
flatChoices.push({
name: choice.message,
value: choice.value,
})
}
return this.styles.primary(this.selected.name)
},
styles: {
dark: chalk.reset,
em: chalk.bgBlack.whiteBright,
success: chalk.reset,
},
type: 'multiselect',
validate (value: string[]) {
if (value.length === 0) {
return 'You must choose at least one vulnerability.'
}
return true
},
j () {
return this.down()
},
k () {
return this.up()
},
cancel () {
// By default, canceling the prompt via Ctrl+c throws an empty string.
// The custom cancel function prevents that behavior.
// Otherwise, pnpm CLI would print an error and confuse users.
// See related issue: https://github.com/enquirer/enquirer/issues/225
}
}
const message = 'Choose which vulnerabilities to fix ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)\n\nEnter to start fixing. Ctrl-c to cancel.`
let selectedKeys: string[]
try {
selectedKeys = await checkbox({
choices: flatChoices,
pageSize: interactivePromptPageSize(),
message,
required: true,
validate: (values) => {
if (values.length === 0) {
return 'You must choose at least one vulnerability.'
}
return true
},
theme: {
icon: { checked: '●', unchecked: '○', cursor: '' },
style: {
highlight: (text: string) => text,
},
keybindings: ['vim'],
},
})
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') {
globalInfo('Audit fix canceled')
process.exit(0)
},
} as any) as any // eslint-disable-line @typescript-eslint/no-explicit-any
}
throw err
}
const selectedKeys = new Set(
(selectedVulnerabilities as AuditChoiceRow[]).map((c) => c.value)
)
const selectedKeySet = new Set(selectedKeys)
const selectedAdvisories = Object.fromEntries(
Object.entries(auditReport.advisories)
.filter(([, advisory]) =>
selectedKeys.has(`${advisory.module_name}@${advisory.vulnerable_versions}`)
selectedKeySet.has(`${advisory.module_name}@${advisory.vulnerable_versions}`)
)
)
return { ...auditReport, advisories: selectedAdvisories }
}

View File

@@ -28,6 +28,7 @@ const COLUMN_HEADER = ['Package', 'Vulnerable', 'Patched', 'Advisories']
export interface AuditChoiceRow {
name: string
value: string
message: string
disabled?: boolean
}
@@ -89,6 +90,7 @@ export function getAuditFixChoices (advisories: AuditAdvisory[]): AuditChoiceGro
if (i === 0) {
return {
name: rendered[i],
message: rendered[i],
value: '',
disabled: true,
hint: '',

View File

@@ -9,11 +9,27 @@ import { readYamlFileSync } from 'read-yaml-file'
import { AUDIT_REGISTRY, AUDIT_REGISTRY_OPTS } from './utils/options.js'
import * as responses from './utils/responses/index.js'
jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } }))
const { default: enquirer } = await import('enquirer')
jest.unstable_mockModule('@inquirer/prompts', () => {
class Separator {
separator: string
readonly type = 'separator' as const
constructor (separator: string) {
this.separator = separator
}
}
return {
Separator,
checkbox: jest.fn(),
confirm: jest.fn(),
input: jest.fn(),
password: jest.fn(),
select: jest.fn(),
}
})
const { checkbox, Separator } = await import('@inquirer/prompts')
const { audit } = await import('@pnpm/deps.compliance.commands')
const prompt = jest.mocked(enquirer.prompt)
const mockCheckbox = jest.mocked(checkbox)
const f = fixtures(import.meta.dirname)
@@ -23,7 +39,7 @@ beforeEach(async () => {
afterEach(async () => {
await teardownMockAgent()
prompt.mockClear()
mockCheckbox.mockClear()
})
test('audit --fix -i shows interactive prompt and only fixes selected vulnerabilities', async () => {
@@ -33,15 +49,7 @@ test('audit --fix -i shows interactive prompt and only fixes selected vulnerabil
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
.reply(200, responses.ALL_VULN_RESP)
// Mock the user selecting only the xmlhttprequest-ssl critical advisory
prompt.mockResolvedValue({
selectedVulnerabilities: [
{
value: 'xmlhttprequest-ssl@<1.6.1',
name: 'xmlhttprequest-ssl@<1.6.1',
},
],
})
mockCheckbox.mockResolvedValue(['xmlhttprequest-ssl@<1.6.1'])
const { exitCode, output } = await audit.handler({
...AUDIT_REGISTRY_OPTS,
@@ -74,14 +82,7 @@ test('audit --fix -i prompt is called with correct structure', async () => {
.reply(200, responses.ALL_VULN_RESP)
// Mock selecting one advisory so the fix proceeds
prompt.mockResolvedValue({
selectedVulnerabilities: [
{
value: 'xmlhttprequest-ssl@<1.6.1',
name: 'xmlhttprequest-ssl@<1.6.1',
},
],
})
mockCheckbox.mockResolvedValue(['xmlhttprequest-ssl@<1.6.1'])
await audit.handler({
...AUDIT_REGISTRY_OPTS,
@@ -91,26 +92,29 @@ test('audit --fix -i prompt is called with correct structure', async () => {
interactive: true,
})
expect(prompt).toHaveBeenCalledWith(
expect(mockCheckbox).toHaveBeenCalledWith(
expect.objectContaining({
footer: '\nEnter to start fixing. Ctrl-c to cancel.',
message:
'Choose which vulnerabilities to fix ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'selectedVulnerabilities',
type: 'multiselect',
`${chalk.cyan('<i>')} to invert selection)` +
'\n\nEnter to start fixing. Ctrl-c to cancel.',
pageSize: process.stdout.rows == null ? 7 : Math.max(7, process.stdout.rows - 6),
})
)
// Verify choices are grouped by severity
const choices = (prompt.mock.calls[0][0] as unknown as Record<string, unknown>).choices as Array<{ name: string }>
const groupNames = choices.map((g) => g.name)
// Should have severity groups (order: critical, high, moderate, low)
expect(groupNames[0]).toBe('[critical]')
expect(groupNames[1]).toBe('[high]')
expect(groupNames[2]).toBe('[moderate]')
const callArgs = mockCheckbox.mock.calls[0][0]
expect((callArgs.theme?.style?.highlight as (str: string) => string)?.('focused row')).toBe('focused row')
const choices = callArgs.choices as Array<{ type?: string; name?: string; value?: string }>
const separatorNames = choices
.filter((c) => c instanceof Separator || c.type === 'separator')
.map((c) => c instanceof Separator ? c.separator : String(c))
expect(separatorNames.some((s: string) => s.includes('critical'))).toBe(true)
expect(separatorNames.some((s: string) => s.includes('high'))).toBe(true)
expect(separatorNames.some((s: string) => s.includes('moderate'))).toBe(true)
})
test('audit --fix -i collapses advisories that share module_name@vulnerable_versions', async () => {
@@ -120,11 +124,7 @@ test('audit --fix -i collapses advisories that share module_name@vulnerable_vers
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
.reply(200, responses.ALL_VULN_RESP)
prompt.mockResolvedValue({
selectedVulnerabilities: [
{ value: 'minimatch@<3.1.3', name: 'minimatch@<3.1.3' },
],
})
mockCheckbox.mockResolvedValue(['minimatch@<3.1.3'])
await audit.handler({
...AUDIT_REGISTRY_OPTS,
@@ -134,14 +134,12 @@ test('audit --fix -i collapses advisories that share module_name@vulnerable_vers
interactive: true,
})
// The mock fixture has 2 distinct advisories for minimatch@<3.1.3 with
// different GHSA IDs; they must render as a single interactive choice
// whose rendered row lists both GHSA IDs.
const choices = (prompt.mock.calls[0][0] as unknown as Record<string, unknown>).choices as Array<{ choices: Array<{ value: string, message?: string }> }>
const allRows = choices.flatMap((g) => g.choices)
const minimatchRows = allRows.filter((c) => c.value === 'minimatch@<3.1.3')
const callArgs = mockCheckbox.mock.calls[0][0]
const choices = callArgs.choices as Array<Record<string, unknown>>
const valueChoices = choices.filter((c) => 'value' in c)
const minimatchRows = valueChoices.filter((c) => c.value === 'minimatch@<3.1.3')
expect(minimatchRows).toHaveLength(1)
expect(minimatchRows[0].message).toMatch(/GHSA-3ppc-4f35-3m26.*GHSA-7r86-[a-z0-9-]+/)
expect(String(minimatchRows[0].name)).toMatch(/GHSA-3ppc-4f35-3m26.*GHSA-7r86-[a-z0-9-]+/)
})
test('audit --fix -i with auditLevel filters before showing prompt', async () => {
@@ -151,14 +149,7 @@ test('audit --fix -i with auditLevel filters before showing prompt', async () =>
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
.reply(200, responses.ALL_VULN_RESP)
prompt.mockResolvedValue({
selectedVulnerabilities: [
{
value: 'xmlhttprequest-ssl@<1.6.1',
name: 'xmlhttprequest-ssl@<1.6.1',
},
],
})
mockCheckbox.mockResolvedValue(['xmlhttprequest-ssl@<1.6.1'])
await audit.handler({
...AUDIT_REGISTRY_OPTS,
@@ -169,8 +160,11 @@ test('audit --fix -i with auditLevel filters before showing prompt', async () =>
interactive: true,
})
// Verify only critical severity group is shown
const choices = (prompt.mock.calls[0][0] as unknown as Record<string, unknown>).choices as Array<{ name: string }>
const groupNames = choices.map((g) => g.name)
expect(groupNames).toStrictEqual(['[critical]'])
const callArgs = mockCheckbox.mock.calls[0][0]
const choices = callArgs.choices as Array<Record<string, unknown>>
const separatorNames = choices
.filter((c) => c instanceof Separator || c.type === 'separator')
.map((c) => c instanceof Separator ? c.separator : String(c))
expect(separatorNames.filter((s: string) => s.includes('critical') || s.includes('high') || s.includes('moderate') || s.includes('low'))).toHaveLength(1)
expect(separatorNames.some((s: string) => s.includes('critical'))).toBe(true)
})

View File

@@ -33,6 +33,7 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/bins.resolver": "workspace:*",
"@pnpm/building.commands": "workspace:*",
"@pnpm/catalogs.resolver": "workspace:*",
@@ -62,7 +63,6 @@
"@pnpm/workspace.projects-sorter": "workspace:*",
"@zkochan/rimraf": "catalog:",
"didyoumean2": "catalog:",
"enquirer": "catalog:",
"execa": "catalog:",
"p-limit": "catalog:",
"ramda": "catalog:",

View File

@@ -1,9 +1,9 @@
import { confirm } from '@inquirer/prompts'
import type { Config, VerifyDepsBeforeRun } from '@pnpm/config.reader'
import { checkDepsStatus, type CheckDepsStatusOptions, type WorkspaceStateSettings } from '@pnpm/deps.status'
import { PnpmError } from '@pnpm/error'
import { runPnpmCli } from '@pnpm/exec.pnpm-cli-runner'
import { globalWarn } from '@pnpm/logger'
import enquirer from 'enquirer'
export interface RunDepsStatusCheckOptions extends CheckDepsStatusOptions {
dir: string
@@ -35,22 +35,21 @@ export async function runDepsStatusCheck (opts: RunDepsStatusCheckOptions): Prom
hint: 'Run "pnpm install" before running scripts. The "verifyDepsBeforeRun: prompt" setting cannot prompt for confirmation in non-interactive environments.',
})
}
let confirmed: { runInstall: boolean }
let confirmed: boolean
try {
confirmed = await enquirer.prompt<{ runInstall: boolean }>({
type: 'confirm',
name: 'runInstall',
confirmed = await confirm({
message: `Your "node_modules" directory is out of sync with the "pnpm-lock.yaml" file. This can lead to issues during scripts execution.
Would you like to run "pnpm ${command.join(' ')}" to update your "node_modules"?`,
initial: true,
default: true,
})
} catch {
// User cancelled the prompt (e.g. Ctrl+C) — exit immediately
// so the caller doesn't proceed to run the script.
process.exit(1)
} catch (err: unknown) {
if (err instanceof Error && err.name === 'ExitPromptError') {
process.exit(1)
}
throw err
}
if (confirmed.runInstall) {
if (confirmed) {
install()
}
break

View File

@@ -15,16 +15,30 @@ jest.unstable_mockModule('@pnpm/logger', () => {
}
})
jest.unstable_mockModule('enquirer', () => ({
default: {
prompt: jest.fn(),
},
}))
jest.unstable_mockModule('@inquirer/prompts', () => {
class Separator {
separator: string
readonly type = 'separator' as const
constructor (separator: string) {
this.separator = separator
}
}
return {
Separator,
checkbox: jest.fn(),
confirm: jest.fn(),
input: jest.fn(),
password: jest.fn(),
select: jest.fn(),
}
})
const { run } = await import('@pnpm/exec.commands')
const { default: enquirer } = await import('enquirer')
const { confirm } = await import('@inquirer/prompts')
const { globalWarn } = await import('@pnpm/logger')
const mockConfirm = jest.mocked(confirm)
const rootProjectManifest = {
name: 'root',
private: true,
@@ -84,8 +98,7 @@ test('log a warning if verifyDepsBeforeRun is set to warn', async () => {
test('prompt the user if verifyDepsBeforeRun is set to prompt', async () => {
prepare(rootProjectManifest)
// Mock the user confirming the prompt
jest.mocked(enquirer.prompt).mockResolvedValue({ runInstall: true })
mockConfirm.mockResolvedValue(true)
const originalIsTTY = process.stdin.isTTY
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true })
@@ -96,14 +109,14 @@ test('prompt the user if verifyDepsBeforeRun is set to prompt', async () => {
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true })
}
expect(enquirer.prompt).toHaveBeenCalledWith({
type: 'confirm',
name: 'runInstall',
message: expect.stringContaining(
'Your "node_modules" directory is out of sync with the "pnpm-lock.yaml" file'
),
initial: true,
})
expect(mockConfirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining(
'Your "node_modules" directory is out of sync with the "pnpm-lock.yaml" file'
),
default: true,
})
)
expect(fs.existsSync(path.resolve('node_modules'))).toBeTruthy()
})

View File

@@ -32,6 +32,7 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/building.after-install": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/cli.command": "workspace:*",
@@ -82,7 +83,6 @@
"@zkochan/table": "catalog:",
"chalk": "catalog:",
"ci-info": "catalog:",
"enquirer": "catalog:",
"get-npm-tarball-url": "catalog:",
"is-subdir": "catalog:",
"load-json-file": "catalog:",

View File

@@ -1,8 +1,8 @@
import { confirm } from '@inquirer/prompts'
import { PnpmError } from '@pnpm/error'
import { globalInfo } from '@pnpm/logger'
import { MINIMUM_RELEASE_AGE_VIOLATION_CODE } from '@pnpm/resolving.npm-resolver'
import { isCI } from 'ci-info'
import enquirer from 'enquirer'
/**
* Shape returned by `installing/deps-installer`'s
@@ -241,13 +241,17 @@ async function promptForApproval (immature: readonly PolicyViolation[]): Promise
`${sorted.length} ${sorted.length === 1 ? 'version does' : 'versions do'} not meet the minimumReleaseAge constraint:\n` +
sorted.map((v) => ` ${v.name}@${v.version}`).join('\n') + '\n' +
'Add to minimumReleaseAgeExclude in pnpm-workspace.yaml and proceed with the install?'
const answer = await enquirer.prompt<{ confirmed: boolean }>({
type: 'confirm',
name: 'confirmed',
message,
initial: false,
})
if (!answer.confirmed) {
let confirmed: boolean
try {
confirmed = await confirm({ message, default: false })
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') {
confirmed = false
} else {
throw err
}
}
if (!confirmed) {
throw new PnpmError(
'MINIMUM_RELEASE_AGE_DENIED',
'Aborted: the immature versions were not approved.',

View File

@@ -9,6 +9,7 @@ import { and, groupBy, isEmpty, pickBy, pipe, pluck, uniqBy } from 'ramda'
export interface ChoiceRow {
name: string
value: string
message: string
disabled?: boolean
}
@@ -71,6 +72,7 @@ export function getUpdateChoices (outdatedPkgsOfProjects: OutdatedPackage[], wor
if (i === 0) {
return {
name: renderedTable[i],
message: renderedTable[i],
value: '',
disabled: true,
hint: '',

View File

@@ -1,7 +1,9 @@
import { checkbox, Separator } from '@inquirer/prompts'
import type { CommandHandler, CommandHandlerMap, CompletionFunc } from '@pnpm/cli.command'
import { FILTERING, OPTIONS, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-options-help'
import {
docsUrl,
interactivePromptPageSize,
readDepNameCompletions,
readProjectManifestOnly,
} from '@pnpm/cli.utils'
@@ -14,15 +16,14 @@ import type { UpdateMatchingFunction } from '@pnpm/installing.deps-installer'
import { globalInfo } from '@pnpm/logger'
import type { IncludedDependencies, PackageVulnerabilityAudit, ProjectRootDir } from '@pnpm/types'
import chalk from 'chalk'
import enquirer from 'enquirer'
import { pick, pluck, unnest } from 'ramda'
import { pick, unnest } from 'ramda'
import { renderHelp } from 'render-help'
import type { InstallCommandOptions } from '../install.js'
import { installDeps } from '../installDeps.js'
import { parseUpdateParam } from '../recursive.js'
import { createGlobalPolicyCallbacks } from '../resolutionPolicyManifest.js'
import { type ChoiceRow, getUpdateChoices } from './getUpdateChoices.js'
import { getUpdateChoices } from './getUpdateChoices.js'
export function rcOptionsTypes (): Record<string, unknown> {
return pick([
'cache-dir',
@@ -219,71 +220,62 @@ async function interactiveUpdate (
timeout: opts.fetchTimeout,
})
const workspacesEnabled = !!opts.workspaceDir
const choices = getUpdateChoices(unnest(outdatedPkgsOfProjects), workspacesEnabled)
if (choices.length === 0) {
const choiceGroups = getUpdateChoices(unnest(outdatedPkgsOfProjects), workspacesEnabled)
if (choiceGroups.length === 0) {
if (opts.latest) {
return 'All of your dependencies are already up to date'
}
return 'All of your dependencies are already up to date inside the specified ranges. Use the --latest option to update the ranges in package.json'
}
const { updateDependencies } = await enquirer.prompt({
choices,
footer: '\nEnter to start updating. Ctrl-c to cancel.',
indicator (state: any, choice: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
return ` ${choice.enabled ? '●' : '○'}`
},
message: 'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
pointer: '',
result () {
return this.selected
},
format () {
if (!this.state.submitted || this.state.cancelled) return ''
if (Array.isArray(this.selected)) {
return this.selected
// The custom format function is used to filter out "[dependencies]" or "[devDependencies]" from the output.
// https://github.com/enquirer/enquirer/blob/master/lib/prompts/select.js#L98
.filter((choice: ChoiceRow) => !/^\[.+\]$/.test(choice.name))
.map((choice: ChoiceRow) => this.styles.primary(choice.name)).join(', ')
const flatChoices: Array<Separator | { name: string; value: string; disabled?: boolean | string }> = []
for (const group of choiceGroups) {
flatChoices.push(new Separator(chalk.bold(`── ${group.message} ──`)))
for (const choice of group.choices) {
if (choice.disabled) {
flatChoices.push(new Separator(` ${choice.message ?? choice.name}`))
} else {
flatChoices.push({
name: choice.message,
value: choice.value,
})
}
return this.styles.primary(this.selected.name)
},
styles: {
dark: chalk.reset,
em: chalk.bgBlack.whiteBright,
success: chalk.reset,
},
type: 'multiselect',
validate (value: string[]) {
if (value.length === 0) {
return 'You must choose at least one package.'
}
return true
},
}
}
// For Vim users (related: https://github.com/enquirer/enquirer/pull/163)
j () {
return this.down()
},
k () {
return this.up()
},
cancel () {
// By default, canceling the prompt via Ctrl+c throws an empty string.
// The custom cancel function prevents that behavior.
// Otherwise, pnpm CLI would print an error and confuse users.
// See related issue: https://github.com/enquirer/enquirer/issues/225
const message = 'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)\n\nEnter to start updating. Ctrl-c to cancel.`
let updatePkgNames: string[]
try {
updatePkgNames = await checkbox({
choices: flatChoices,
pageSize: interactivePromptPageSize(),
message,
required: true,
validate: (values) => {
if (values.length === 0) {
return 'You must choose at least one package.'
}
return true
},
theme: {
icon: { checked: '●', unchecked: '○', cursor: '' },
style: {
highlight: (text: string) => text,
},
keybindings: ['vim'],
},
})
} catch (err) {
if (err instanceof Error && err.name === 'ExitPromptError') {
globalInfo('Update canceled')
process.exit(0)
},
} as any) as any // eslint-disable-line @typescript-eslint/no-explicit-any
}
throw err
}
const updatePkgNames = pluck('value', updateDependencies as ChoiceRow[])
return update(updatePkgNames, opts, rebuildHandler) as Promise<undefined>
}

View File

@@ -86,6 +86,7 @@ test('getUpdateChoices()', () => {
choices: [
{
name: 'Package Current Target URL ',
message: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',
@@ -103,6 +104,7 @@ test('getUpdateChoices()', () => {
choices: [
{
name: 'Package Current Target URL ',
message: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',
@@ -130,6 +132,7 @@ test('getUpdateChoices()', () => {
choices: [
{
name: 'Package Current Target URL ',
message: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',

View File

@@ -9,11 +9,27 @@ import { filterProjectsBySelectorObjectsFromDir } from '@pnpm/workspace.projects
import chalk from 'chalk'
import { readYamlFileSync } from 'read-yaml-file'
jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } }))
const { default: enquirer } = await import('enquirer')
jest.unstable_mockModule('@inquirer/prompts', () => {
class Separator {
separator: string
readonly type = 'separator' as const
constructor (separator: string) {
this.separator = separator
}
}
return {
Separator,
checkbox: jest.fn(),
confirm: jest.fn(),
input: jest.fn(),
password: jest.fn(),
select: jest.fn(),
}
})
const { checkbox, Separator } = await import('@inquirer/prompts')
const { add, install, update } = await import('@pnpm/installing.commands')
const prompt = jest.mocked(enquirer.prompt)
const mockCheckbox = jest.mocked(checkbox)
const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}`
@@ -61,13 +77,6 @@ test('interactively update', async () => {
const storeDir = path.resolve('pnpm-store')
const headerChoice = {
name: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',
}
await Promise.all([
addDistTag({ package: 'is-negative', version: '2.1.0', distTag: 'latest' }),
addDistTag({ package: 'micromatch', version: '4.0.0', distTag: 'latest' }),
@@ -85,16 +94,9 @@ test('interactively update', async () => {
['is-negative@1.0.0', 'is-positive@2.0.0', 'micromatch@3.0.0']
)
prompt.mockResolvedValue({
updateDependencies: [
{
value: 'is-negative',
name: `is-negative 1.0.0 1.0.${chalk.greenBright.bold('1')} https://pnpm.io/ `,
},
],
})
mockCheckbox.mockResolvedValue(['is-negative'])
prompt.mockClear()
mockCheckbox.mockClear()
// Update to compatible versions
await update.handler({
...DEFAULT_OPTIONS,
@@ -106,37 +108,32 @@ test('interactively update', async () => {
})
// eslint-disable-next-line
expect((prompt.mock.calls[0][0] as any).choices).toStrictEqual([
const callArgs = mockCheckbox.mock.calls[0][0] as any
const flatChoices = callArgs.choices
expect(flatChoices).toStrictEqual([
new Separator(chalk.bold('── dependencies ──')),
new Separator(' Package Current Target URL '),
{
choices: [
headerChoice,
{
message: `is-negative 1.0.0 1.0.${chalk.greenBright.bold('1')} `,
value: 'is-negative',
name: 'is-negative',
},
{
message: `micromatch 3.0.0 3.${chalk.yellowBright.bold('1.10')} `,
value: 'micromatch',
name: 'micromatch',
},
],
name: '[dependencies]',
message: 'dependencies',
name: `is-negative 1.0.0 1.0.${chalk.greenBright.bold('1')} `,
value: 'is-negative',
},
{
name: `micromatch 3.0.0 3.${chalk.yellowBright.bold('1.10')} `,
value: 'micromatch',
},
])
expect(prompt).toHaveBeenCalledWith(
expect(mockCheckbox).toHaveBeenCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
`${chalk.cyan('<i>')} to invert selection)\n\nEnter to start updating. Ctrl-c to cancel.`,
pageSize: process.stdout.rows == null ? 7 : Math.max(7, process.stdout.rows - 6),
})
)
expect(callArgs.theme.style.highlight('focused row')).toBe('focused row')
{
const lockfile = project.readLockfile()
@@ -147,7 +144,8 @@ test('interactively update', async () => {
}
// Update to latest versions
prompt.mockClear()
mockCheckbox.mockClear()
mockCheckbox.mockResolvedValue(['is-negative'])
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
@@ -159,40 +157,32 @@ test('interactively update', async () => {
})
// eslint-disable-next-line
expect((prompt.mock.calls[0][0] as any).choices).toStrictEqual([
const callArgs2 = mockCheckbox.mock.calls[0][0] as any
const flatChoices2 = callArgs2.choices
expect(flatChoices2).toStrictEqual([
new Separator(chalk.bold('── dependencies ──')),
new Separator(' Package Current Target URL '),
{
choices: [
headerChoice,
{
message: `is-negative 1.0.1 ${chalk.redBright.bold('2.1.0')} `,
value: 'is-negative',
name: 'is-negative',
},
{
message: `is-positive 2.0.0 ${chalk.redBright.bold('3.1.0')} `,
value: 'is-positive',
name: 'is-positive',
},
{
message: `micromatch 3.0.0 ${chalk.redBright.bold('4.0.0')} `,
value: 'micromatch',
name: 'micromatch',
},
],
name: '[dependencies]',
message: 'dependencies',
name: `is-negative 1.0.1 ${chalk.redBright.bold('2.1.0')} `,
value: 'is-negative',
},
{
name: `is-positive 2.0.0 ${chalk.redBright.bold('3.1.0')} `,
value: 'is-positive',
},
{
name: `micromatch 3.0.0 ${chalk.redBright.bold('4.0.0')} `,
value: 'micromatch',
},
])
expect(prompt).toHaveBeenCalledWith(
expect(mockCheckbox).toHaveBeenCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
`${chalk.cyan('<i>')} to invert selection)\n\nEnter to start updating. Ctrl-c to cancel.`,
})
)
@@ -224,14 +214,7 @@ test('interactive update of dev dependencies only', async () => {
])
const storeDir = path.resolve('store')
prompt.mockResolvedValue({
updateDependencies: [
{
value: 'is-negative',
name: `is-negative 1.0.0 1.0.${chalk.greenBright.bold('1')} https://pnpm.io/ `,
},
],
})
mockCheckbox.mockResolvedValue(['is-negative'])
const { allProjects, selectedProjectsGraph } = await filterProjectsBySelectorObjectsFromDir(
process.cwd(),
@@ -303,11 +286,9 @@ test('interactively update should ignore dependencies from the ignoreDependencie
['is-negative@1.0.0', 'is-positive@2.0.0', 'micromatch@3.0.0']
)
prompt.mockResolvedValue({
updateDependencies: [{ value: 'micromatch', name: 'anything' }],
})
mockCheckbox.mockResolvedValue(['micromatch'])
prompt.mockClear()
mockCheckbox.mockClear()
await update.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
@@ -321,38 +302,27 @@ test('interactively update should ignore dependencies from the ignoreDependencie
})
// eslint-disable-next-line
expect((prompt.mock.calls[0][0] as any).choices as any).toStrictEqual(
const callArgs3 = mockCheckbox.mock.calls[0][0] as any
const flatChoices3 = callArgs3.choices
expect(flatChoices3).toStrictEqual(
[
new Separator(chalk.bold('── dependencies ──')),
new Separator(' Package Current Target URL '),
{
choices: [
{
disabled: true,
hint: '',
name: 'Package Current Target URL ',
value: '',
},
{
message: `micromatch 3.0.0 3.${chalk.yellowBright.bold('1.10')} `,
value: 'micromatch',
name: 'micromatch',
},
],
name: '[dependencies]',
message: 'dependencies',
name: `micromatch 3.0.0 3.${chalk.yellowBright.bold('1.10')} `,
value: 'micromatch',
},
]
)
expect(prompt).toHaveBeenCalledWith(
expect(mockCheckbox).toHaveBeenCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
`${chalk.cyan('<i>')} to invert selection)\n\nEnter to start updating. Ctrl-c to cancel.`,
})
)

View File

@@ -5,12 +5,28 @@ import { preparePackages } from '@pnpm/prepare'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { filterProjectsBySelectorObjectsFromDir } from '@pnpm/workspace.projects-filter'
jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } }))
jest.unstable_mockModule('@inquirer/prompts', () => {
class Separator {
separator: string
readonly type = 'separator' as const
constructor (separator: string) {
this.separator = separator
}
}
return {
Separator,
checkbox: jest.fn(),
confirm: jest.fn(),
input: jest.fn(),
password: jest.fn(),
select: jest.fn(),
}
})
const { default: enquirer } = await import('enquirer')
const { checkbox } = await import('@inquirer/prompts')
const { update, install } = await import('@pnpm/installing.commands')
const prompt = jest.mocked(enquirer.prompt)
const mockCheckbox = jest.mocked(checkbox)
const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}`
@@ -60,9 +76,7 @@ test('interactive recursive should not error on git specifier override', async (
},
])
prompt.mockResolvedValue({
updateDependencies: [],
})
mockCheckbox.mockResolvedValue([])
const { allProjects, selectedProjectsGraph } = await filterProjectsBySelectorObjectsFromDir(process.cwd(), [])
const sharedOptions = {

View File

@@ -56,6 +56,7 @@
".test": "cross-env PNPM_REGISTRY_MOCK_PORT=7769 NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/agent.client": "workspace:*",
"@pnpm/bins.linker": "workspace:*",
"@pnpm/bins.remover": "workspace:*",
@@ -109,7 +110,6 @@
"@pnpm/util.lex-comparator": "catalog:",
"@pnpm/workspace.project-manifest-reader": "workspace:*",
"@zkochan/rimraf": "catalog:",
"enquirer": "catalog:",
"is-inner-link": "catalog:",
"is-subdir": "catalog:",
"load-json-file": "catalog:",

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { confirm } from '@inquirer/prompts'
import { PnpmError } from '@pnpm/error'
import type {
IncludedDependencies,
@@ -13,7 +14,6 @@ import {
type Registries,
} from '@pnpm/types'
import { rimraf } from '@zkochan/rimraf'
import enquirer from 'enquirer'
import { pathAbsolute } from 'path-absolute'
import { equals } from 'ramda'
@@ -155,15 +155,21 @@ async function purgeModulesDirsOfImporters (
hint: 'If you are running pnpm in CI, set the CI environment variable to "true", or set "confirmModulesPurge" to "false".',
})
}
const confirmed = await enquirer.prompt<{ question: boolean }>({
type: 'confirm',
name: 'question',
message: importers.length === 1
? `The modules directory at "${importers[0].modulesDir}" will be removed and reinstalled from scratch. Proceed?`
: 'The modules directories will be removed and reinstalled from scratch. Proceed?',
initial: true,
})
if (!confirmed.question) {
let confirmed: boolean
try {
confirmed = await confirm({
message: importers.length === 1
? `The modules directory at "${importers[0].modulesDir}" will be removed and reinstalled from scratch. Proceed?`
: 'The modules directories will be removed and reinstalled from scratch. Proceed?',
default: true,
})
} catch (err: unknown) {
if (err instanceof Error && err.name === 'ExitPromptError') {
throw new PnpmError('ABORTED_REMOVE_MODULES_DIR', 'Aborted removal of modules directory')
}
throw err
}
if (!confirmed) {
throw new PnpmError('ABORTED_REMOVE_MODULES_DIR', 'Aborted removal of modules directory')
}
}

View File

@@ -21,8 +21,6 @@ export {
type OtpHandlingParams,
OtpNonInteractiveError,
type OtpProcess,
type OtpPromptOptions,
type OtpPromptResponse,
OtpSecondChallengeError,
SyntheticOtpError,
withOtpHandling,

View File

@@ -7,17 +7,7 @@ import type { PromptBrowserOpenReadlineInterface } from './promptBrowserOpen.js'
import { promptBrowserOpen } from './promptBrowserOpen.js'
export interface OtpEnquirer {
prompt: (options: OtpPromptOptions) => Promise<OtpPromptResponse | undefined>
}
export interface OtpPromptOptions {
message: string
name: 'otp'
type: 'input'
}
export interface OtpPromptResponse {
otp?: string
input: (options: { message: string }) => Promise<string | undefined>
}
interface OtpDate {
@@ -113,14 +103,19 @@ export async function withOtpHandling<T> ({
pollPromise,
})
} else {
const enquirerResponse = await enquirer.prompt({
message: 'This operation requires a one-time password.\nEnter OTP:',
name: 'otp',
type: 'input',
})
let otpValue: string | undefined
try {
otpValue = await enquirer.input({
message: 'This operation requires a one-time password.\nEnter OTP:',
})
} catch (err: unknown) {
if (err instanceof Error && err.name === 'ExitPromptError') {
throw error
}
throw err
}
// Use || (not ??) so that empty-string input is treated as "no OTP provided"
otp = enquirerResponse?.otp || undefined
otp = otpValue || undefined
}
if (otp != null) {

View File

@@ -40,7 +40,7 @@ type MockContextOverrides = Omit<Partial<OtpContext>, 'process'> & {
const createOtpMockContext = (overrides?: MockContextOverrides): OtpContext => ({
Date: { now: () => 0 },
setTimeout: (cb: () => void) => cb(),
enquirer: { prompt: async () => ({ otp: '123456' }) },
enquirer: { input: async () => '123456' },
fetch: async () => createMockResponse({
ok: false,
status: 404,
@@ -103,7 +103,7 @@ describe('withOtpHandling', () => {
it('prompts for OTP and retries operation', async () => {
let callCount = 0
const context = createOtpMockContext({
enquirer: { prompt: async () => ({ otp: '654321' }) },
enquirer: { input: async () => '654321' },
})
const result = await withOtpHandling({
context,
@@ -149,7 +149,7 @@ describe('withOtpHandling', () => {
it('re-throws the original OTP error when enquirer returns no OTP', async () => {
const context = createOtpMockContext({
enquirer: { prompt: async () => ({ otp: '' }) },
enquirer: { input: async () => '' },
})
await expect(withOtpHandling({
context,
@@ -162,7 +162,7 @@ describe('withOtpHandling', () => {
it('re-throws the original OTP error when enquirer returns undefined', async () => {
const context = createOtpMockContext({
enquirer: { prompt: async () => undefined },
enquirer: { input: async () => undefined },
})
await expect(withOtpHandling({
context,
@@ -224,7 +224,7 @@ describe('withOtpHandling', () => {
it('falls back to classic prompt when only authUrl is present (no doneUrl)', async () => {
let callCount = 0
const context = createOtpMockContext({
enquirer: { prompt: async () => ({ otp: 'manual-code' }) },
enquirer: { input: async () => 'manual-code' },
})
const result = await withOtpHandling({
context,
@@ -247,7 +247,7 @@ describe('withOtpHandling', () => {
it('falls back to classic prompt when only doneUrl is present (no authUrl)', async () => {
let callCount = 0
const context = createOtpMockContext({
enquirer: { prompt: async () => ({ otp: 'manual-code' }) },
enquirer: { input: async () => 'manual-code' },
})
const result = await withOtpHandling({
context,

View File

@@ -33,6 +33,7 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/config.writer": "workspace:*",
@@ -54,7 +55,6 @@
"@pnpm/workspace.project-manifest-reader": "workspace:*",
"@pnpm/workspace.workspace-manifest-reader": "workspace:*",
"chalk": "catalog:",
"enquirer": "catalog:",
"escape-string-regexp": "catalog:",
"is-windows": "catalog:",
"make-empty-dir": "catalog:",

View File

@@ -1,12 +1,12 @@
import path from 'node:path'
import { confirm, select } from '@inquirer/prompts'
import type { Config } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import { isGitHostedPkgUrl } from '@pnpm/fetching.pick-fetcher'
import { readCurrentLockfile, type TarballResolution } from '@pnpm/lockfile.fs'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
import { parseWantedDependency, type ParseWantedDependencyResult } from '@pnpm/resolving.parse-wanted-dependency'
import enquirer from 'enquirer'
import { realpathMissing } from 'realpath-missing'
import semver from 'semver'
@@ -30,28 +30,27 @@ export async function getPatchedDependency (rawDependency: string, opts: GetPatc
dep.alias = dep.alias ?? rawDependency
if (preferredVersions.length > 1) {
const { version, applyToAll } = await enquirer.prompt<{
version: string
applyToAll: boolean
}>([{
type: 'select',
name: 'version',
message: 'Choose which version to patch',
choices: preferredVersions.map(preferred => ({
name: preferred.version,
message: preferred.version,
value: preferred.gitTarballUrl ?? preferred.version,
hint: preferred.gitTarballUrl ? 'Git Hosted' : undefined,
})),
result (selected) {
const selectedVersion = preferredVersions.find(preferred => preferred.version === selected)!
return selectedVersion.gitTarballUrl ?? selected
},
}, {
type: 'confirm',
name: 'applyToAll',
message: 'Apply this patch to all versions?',
}])
let version: string
let applyToAll: boolean
try {
version = await select({
message: 'Choose which version to patch',
choices: preferredVersions.map(preferred => ({
name: preferred.version,
value: preferred.gitTarballUrl ?? preferred.version,
description: preferred.gitTarballUrl ? 'Git Hosted' : undefined,
})),
theme: { keybindings: ['vim'] },
})
applyToAll = await confirm({
message: 'Apply this patch to all versions?',
})
} catch (err: unknown) {
if (err instanceof Error && err.name === 'ExitPromptError') {
throw new PnpmError('PATCH_CANCELED', 'Canceled')
}
throw err
}
return {
...dep,
applyToAll,

View File

@@ -1,11 +1,11 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { checkbox } from '@inquirer/prompts'
import { docsUrl } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import { install } from '@pnpm/installing.commands'
import enquirer from 'enquirer'
import { pick } from 'ramda'
import { renderHelp } from 'render-help'
@@ -40,17 +40,22 @@ export async function handler (opts: PatchRemoveCommandOptions, params: string[]
if (!params.length) {
const allPatches = Object.keys(patchedDependencies)
if (allPatches.length) {
({ patches: patchesToRemove } = await enquirer.prompt<{
patches: string[]
}>({
type: 'multiselect',
name: 'patches',
message: 'Select the patch to be removed',
choices: allPatches,
validate (value) {
return value.length === 0 ? 'Select at least one option.' : true
},
}))
try {
patchesToRemove = await checkbox({
choices: allPatches.map((name) => ({ name, value: name })),
message: 'Select the patch to be removed',
required: true,
validate: (values) => {
return values.length === 0 ? 'Select at least one option.' : true
},
theme: { keybindings: ['vim'] },
})
} catch (err: unknown) {
if (err instanceof Error && err.name === 'ExitPromptError') {
throw new PnpmError('PATCH_REMOVE_CANCELED', 'Canceled')
}
throw err
}
}
}

View File

@@ -16,11 +16,30 @@ import { writeYamlFileSync } from 'write-yaml-file'
import { DEFAULT_OPTS } from './utils/index.js'
jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } }))
const { default: enquirer } = await import('enquirer')
jest.unstable_mockModule('@inquirer/prompts', () => {
class Separator {
separator: string
readonly type = 'separator' as const
constructor (separator: string) {
this.separator = separator
}
}
return {
Separator,
checkbox: jest.fn(),
confirm: jest.fn(),
input: jest.fn(),
password: jest.fn(),
select: jest.fn(),
}
})
const { checkbox: mockCheckboxFn, select, confirm: confirmPrompt } = await import('@inquirer/prompts')
const { patch, patchCommit, patchRemove } = await import('@pnpm/patching.commands')
const prompt = jest.mocked(enquirer.prompt)
const mockSelect = jest.mocked(select)
const mockConfirm = jest.mocked(confirmPrompt)
const mockCheckbox = jest.mocked(mockCheckboxFn)
const f = fixtures(import.meta.dirname)
const basePatchOption = {
@@ -642,24 +661,23 @@ describe('multiple versions', () => {
dir: process.cwd(),
saveLockfile: true,
})
prompt.mockResolvedValue({
version: '1.0.0',
applyToAll: true,
})
prompt.mockClear()
mockSelect.mockResolvedValue('1.0.0')
mockConfirm.mockResolvedValue(true)
mockSelect.mockClear()
mockConfirm.mockClear()
const output = await patch.handler(defaultPatchOption, ['@pnpm.e2e/console-log'])
expect(prompt.mock.calls).toMatchObject([[[
{
type: 'select',
name: 'version',
choices: ['1.0.0', '2.0.0', '3.0.0'].map(x => ({ name: x, message: x, value: x })),
},
{
type: 'confirm',
name: 'applyToAll',
},
]]] as unknown as Record<string, unknown>[])
expect(mockSelect).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Choose which version to patch',
choices: ['1.0.0', '2.0.0', '3.0.0'].map(x => expect.objectContaining({ name: x, value: x })),
})
)
expect(mockConfirm).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Apply this patch to all versions?',
})
)
const patchDir = getPatchDirFromPatchOutput(output)
const fileToPatch = path.join(patchDir, 'index.js')
@@ -737,35 +755,26 @@ describe('prompt to choose version', () => {
dir: process.cwd(),
saveLockfile: true,
})
prompt.mockResolvedValue({
version: '5.3.0',
applyToAll: false,
})
prompt.mockClear()
mockSelect.mockResolvedValue('5.3.0')
mockConfirm.mockResolvedValue(false)
mockSelect.mockClear()
mockConfirm.mockClear()
const output = await patch.handler(defaultPatchOption, ['chalk'])
expect(prompt.mock.calls).toMatchObject([[[
{
type: 'select',
name: 'version',
expect(mockSelect).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Choose which version to patch',
choices: [
{
name: '4.1.2',
message: '4.1.2',
value: '4.1.2',
},
{
name: '5.3.0',
message: '5.3.0',
value: '5.3.0',
},
expect.objectContaining({ name: '4.1.2', value: '4.1.2' }),
expect.objectContaining({ name: '5.3.0', value: '5.3.0' }),
],
},
{
type: 'confirm',
name: 'applyToAll',
},
]]] as unknown as Record<string, unknown>[])
})
)
expect(mockConfirm).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Apply this patch to all versions?',
})
)
const patchDir = getPatchDirFromPatchOutput(output)
@@ -804,35 +813,26 @@ describe('prompt to choose version', () => {
dir: process.cwd(),
saveLockfile: true,
})
prompt.mockResolvedValue({
version: '5.3.0',
applyToAll: true,
})
prompt.mockClear()
mockSelect.mockResolvedValue('5.3.0')
mockConfirm.mockResolvedValue(true)
mockSelect.mockClear()
mockConfirm.mockClear()
const output = await patch.handler(defaultPatchOption, ['chalk'])
expect(prompt.mock.calls).toMatchObject([[[
{
type: 'select',
name: 'version',
expect(mockSelect).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Choose which version to patch',
choices: [
{
name: '4.1.2',
message: '4.1.2',
value: '4.1.2',
},
{
name: '5.3.0',
message: '5.3.0',
value: '5.3.0',
},
expect.objectContaining({ name: '4.1.2', value: '4.1.2' }),
expect.objectContaining({ name: '5.3.0', value: '5.3.0' }),
],
},
{
type: 'confirm',
name: 'applyToAll',
},
]]] as unknown as Record<string, unknown>[])
})
)
expect(mockConfirm).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Apply this patch to all versions?',
})
)
const patchDir = getPatchDirFromPatchOutput(output)
@@ -1198,35 +1198,28 @@ describe('patch and commit in workspaces', () => {
saveLockfile: true,
})
prompt.mockResolvedValue({
version: 'https://codeload.github.com/zkochan/hi/tar.gz/4cdebec76b7b9d1f6e219e06c42d92a6b8ea60cd',
applyToAll: false,
})
prompt.mockClear()
mockSelect.mockResolvedValue('https://codeload.github.com/zkochan/hi/tar.gz/4cdebec76b7b9d1f6e219e06c42d92a6b8ea60cd')
mockConfirm.mockResolvedValue(false)
mockSelect.mockClear()
mockConfirm.mockClear()
const output = await patch.handler(defaultPatchOption, ['hi'])
expect(prompt.mock.calls).toMatchObject([[[
{
type: 'select',
name: 'version',
expect(mockSelect).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Choose which version to patch',
choices: [
{
name: '0.0.0',
message: '0.0.0',
value: '0.0.0',
},
{
expect.objectContaining({ name: '0.0.0', value: '0.0.0' }),
expect.objectContaining({
name: '1.0.0',
message: '1.0.0',
value: 'https://codeload.github.com/zkochan/hi/tar.gz/4cdebec76b7b9d1f6e219e06c42d92a6b8ea60cd',
hint: 'Git Hosted',
},
}),
],
},
{
type: 'confirm',
name: 'applyToAll',
},
]]] as unknown as Record<string, unknown>[])
})
)
expect(mockConfirm).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Apply this patch to all versions?',
})
)
const patchDir = getPatchDirFromPatchOutput(output)
expect(fs.existsSync(patchDir)).toBe(true)
expect(fs.readFileSync(path.join(patchDir, 'index.js'), 'utf8')).toContain('module.exports = \'Hi\'')
@@ -1326,7 +1319,9 @@ describe('patch-remove', () => {
let storeDir: string
beforeEach(async () => {
prompt.mockClear()
mockSelect.mockClear()
mockConfirm.mockClear()
mockCheckbox.mockClear()
prepare({
dependencies: {
'is-positive': '1.0.0',
@@ -1384,9 +1379,7 @@ describe('patch-remove', () => {
packages: ['.'],
patchedDependencies,
})
prompt.mockResolvedValue({
patches: ['is-positive@1.0.0', 'chalk@4.1.2'],
})
mockCheckbox.mockResolvedValue(['is-positive@1.0.0', 'chalk@4.1.2'])
const { manifest } = await readProjectManifest(process.cwd())
await patchRemove.handler({
...defaultPatchRemoveOption,
@@ -1394,8 +1387,8 @@ describe('patch-remove', () => {
patchedDependencies,
}, [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((prompt.mock.calls[0][0] as any).choices).toEqual(expect.arrayContaining(['is-positive@1.0.0', 'chalk@4.1.2']))
prompt.mockClear()
expect((mockCheckbox.mock.calls[0][0] as any).choices.map((c: any) => c.value)).toEqual(expect.arrayContaining(['is-positive@1.0.0', 'chalk@4.1.2']))
mockCheckbox.mockClear()
const workspaceManifest = await readWorkspaceManifest(process.cwd())
expect(workspaceManifest!.patchedDependencies).toBeUndefined()

338
pnpm-lock.yaml generated
View File

@@ -292,6 +292,9 @@ catalogs:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
'@inquirer/prompts':
specifier: ^8.4.3
version: 8.4.3
'@jest/globals':
specifier: 30.3.0
version: 30.3.0
@@ -601,9 +604,6 @@ catalogs:
encode-registry:
specifier: ^3.0.1
version: 3.0.1
enquirer:
specifier: ^2.4.1
version: 2.4.1
esbuild:
specifier: ^0.27.7
version: 0.27.7
@@ -1598,6 +1598,9 @@ importers:
auth/commands:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/cli.utils':
specifier: workspace:*
version: link:../../cli/utils
@@ -1616,9 +1619,6 @@ importers:
'@pnpm/registry-access.client':
specifier: workspace:*
version: link:../../registry-access/client
enquirer:
specifier: 'catalog:'
version: 2.4.1
normalize-registry-url:
specifier: 'catalog:'
version: 2.0.1
@@ -1891,6 +1891,9 @@ importers:
building/commands:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/building.after-install':
specifier: workspace:*
version: link:../after-install
@@ -1939,9 +1942,6 @@ importers:
chalk:
specifier: 'catalog:'
version: 5.6.2
enquirer:
specifier: 'catalog:'
version: 2.4.1
p-limit:
specifier: 'catalog:'
version: 7.3.0
@@ -3026,6 +3026,9 @@ importers:
deps/compliance/commands:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/cli.command':
specifier: workspace:*
version: link:../../../cli/command
@@ -3104,9 +3107,6 @@ importers:
chalk:
specifier: 'catalog:'
version: 5.6.2
enquirer:
specifier: 'catalog:'
version: 2.4.1
memoize:
specifier: 'catalog:'
version: 10.2.0
@@ -4262,6 +4262,9 @@ importers:
exec/commands:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/bins.resolver':
specifier: workspace:*
version: link:../../bins/resolver
@@ -4349,9 +4352,6 @@ importers:
didyoumean2:
specifier: 'catalog:'
version: 7.0.4
enquirer:
specifier: 'catalog:'
version: 2.4.1
execa:
specifier: 'catalog:'
version: safe-execa@0.3.0
@@ -5281,6 +5281,9 @@ importers:
installing/commands:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/building.after-install':
specifier: workspace:*
version: link:../../building/after-install
@@ -5431,9 +5434,6 @@ importers:
ci-info:
specifier: 'catalog:'
version: 4.4.0
enquirer:
specifier: 'catalog:'
version: 2.4.1
get-npm-tarball-url:
specifier: 'catalog:'
version: 2.1.0
@@ -5645,6 +5645,9 @@ importers:
installing/deps-installer:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/agent.client':
specifier: workspace:*
version: link:../../agent/client
@@ -5807,9 +5810,6 @@ importers:
'@zkochan/rimraf':
specifier: 'catalog:'
version: 4.0.0
enquirer:
specifier: 'catalog:'
version: 2.4.1
is-inner-link:
specifier: 'catalog:'
version: 5.0.0
@@ -7457,6 +7457,9 @@ importers:
patching/commands:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/cli.utils':
specifier: workspace:*
version: link:../../cli/utils
@@ -7520,9 +7523,6 @@ importers:
chalk:
specifier: 'catalog:'
version: 5.6.2
enquirer:
specifier: 'catalog:'
version: 2.4.1
escape-string-regexp:
specifier: 'catalog:'
version: 5.0.0
@@ -8184,6 +8184,9 @@ importers:
registry-access/commands:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/cli.utils':
specifier: workspace:*
version: link:../../cli/utils
@@ -8220,9 +8223,6 @@ importers:
chalk:
specifier: 'catalog:'
version: 5.6.2
enquirer:
specifier: 'catalog:'
version: 2.4.1
ramda:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'
@@ -8269,6 +8269,9 @@ importers:
releasing/commands:
dependencies:
'@inquirer/prompts':
specifier: 'catalog:'
version: 8.4.3(@types/node@25.9.1)
'@pnpm/bins.resolver':
specifier: workspace:*
version: link:../../bins/resolver
@@ -8377,9 +8380,6 @@ importers:
detect-libc:
specifier: 'catalog:'
version: 2.1.2
enquirer:
specifier: 'catalog:'
version: 2.4.1
execa:
specifier: 'catalog:'
version: safe-execa@0.3.0
@@ -10939,6 +10939,55 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@inquirer/ansi@2.0.5':
resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/checkbox@5.1.5':
resolution: {integrity: sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/confirm@6.0.13':
resolution: {integrity: sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@11.1.10':
resolution: {integrity: sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/editor@5.1.2':
resolution: {integrity: sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/expand@5.0.14':
resolution: {integrity: sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/external-editor@1.0.3':
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
engines: {node: '>=18'}
@@ -10948,10 +10997,95 @@ packages:
'@types/node':
optional: true
'@inquirer/external-editor@3.0.0':
resolution: {integrity: sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@1.0.15':
resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
engines: {node: '>=18'}
'@inquirer/figures@2.0.5':
resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
'@inquirer/input@5.0.13':
resolution: {integrity: sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/number@4.0.13':
resolution: {integrity: sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/password@5.0.13':
resolution: {integrity: sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/prompts@8.4.3':
resolution: {integrity: sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/rawlist@5.2.9':
resolution: {integrity: sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/search@4.1.9':
resolution: {integrity: sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/select@5.1.5':
resolution: {integrity: sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/type@4.0.5':
resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'}
@@ -13778,9 +13912,18 @@ packages:
resolution: {integrity: sha512-PY66/8HelapGo5nqMN17ZTKqJj1nppuS1OoC9Y0aI2jsUDlZDEYhMODTpb68wVCq+xMbaEbPGXRd7qutHzkRXA==}
engines: {node: ^14.13.1 || >=16.0.0}
fast-string-truncated-width@3.0.3:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
fastest-levenshtein@1.0.16:
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
engines: {node: '>= 4.9.1'}
@@ -15315,6 +15458,10 @@ packages:
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
nanoresource@1.3.0:
resolution: {integrity: sha512-OI5dswqipmlYfyL3k/YMm7mbERlh4Bd1KuKdMHpeoVD1iVxqxaTMKleB4qaA2mbQZ6/zMNSxCXv9M9P/YbqTuQ==}
@@ -18266,6 +18413,51 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@inquirer/ansi@2.0.5': {}
'@inquirer/checkbox@5.1.5(@types/node@25.9.1)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/confirm@6.0.13(@types/node@25.9.1)':
dependencies:
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/core@11.1.10(@types/node@25.9.1)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@25.9.1)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.2
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/editor@5.1.2(@types/node@25.9.1)':
dependencies:
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/external-editor': 3.0.0(@types/node@25.9.1)
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/expand@5.0.14(@types/node@25.9.1)':
dependencies:
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/external-editor@1.0.3(@types/node@22.19.19)':
dependencies:
chardet: 2.1.1
@@ -18273,8 +18465,82 @@ snapshots:
optionalDependencies:
'@types/node': 22.19.19
'@inquirer/external-editor@3.0.0(@types/node@25.9.1)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/figures@1.0.15': {}
'@inquirer/figures@2.0.5': {}
'@inquirer/input@5.0.13(@types/node@25.9.1)':
dependencies:
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/number@4.0.13(@types/node@25.9.1)':
dependencies:
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/password@5.0.13(@types/node@25.9.1)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/prompts@8.4.3(@types/node@25.9.1)':
dependencies:
'@inquirer/checkbox': 5.1.5(@types/node@25.9.1)
'@inquirer/confirm': 6.0.13(@types/node@25.9.1)
'@inquirer/editor': 5.1.2(@types/node@25.9.1)
'@inquirer/expand': 5.0.14(@types/node@25.9.1)
'@inquirer/input': 5.0.13(@types/node@25.9.1)
'@inquirer/number': 4.0.13(@types/node@25.9.1)
'@inquirer/password': 5.0.13(@types/node@25.9.1)
'@inquirer/rawlist': 5.2.9(@types/node@25.9.1)
'@inquirer/search': 4.1.9(@types/node@25.9.1)
'@inquirer/select': 5.1.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/rawlist@5.2.9(@types/node@25.9.1)':
dependencies:
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/search@4.1.9(@types/node@25.9.1)':
dependencies:
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/select@5.1.5(@types/node@25.9.1)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/core': 11.1.10(@types/node@25.9.1)
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@25.9.1)
optionalDependencies:
'@types/node': 25.9.1
'@inquirer/type@4.0.5(@types/node@25.9.1)':
optionalDependencies:
'@types/node': 25.9.1
'@isaacs/cliui@9.0.0': {}
'@isaacs/fs-minipass@4.0.1':
@@ -22073,8 +22339,18 @@ snapshots:
fast-string-compare@3.0.0: {}
fast-string-truncated-width@3.0.3: {}
fast-string-width@3.0.2:
dependencies:
fast-string-truncated-width: 3.0.3
fast-uri@3.1.2: {}
fast-wrap-ansi@0.2.2:
dependencies:
fast-string-width: 3.0.2
fastest-levenshtein@1.0.16: {}
fastq@1.20.1:
@@ -24020,6 +24296,8 @@ snapshots:
mute-stream@1.0.0: {}
mute-stream@3.0.0: {}
nanoresource@1.3.0:
dependencies:
inherits: 2.0.4

View File

@@ -79,6 +79,7 @@ catalog:
'@commitlint/prompt-cli': ^20.5.3
'@cyclonedx/cyclonedx-library': 10.0.0
'@eslint/js': ^10.0.1
'@inquirer/prompts': ^8.4.3
'@jest/globals': 30.3.0
'@npm/types': ^2.1.0
'@pnpm/byline': ^1.0.0
@@ -182,7 +183,6 @@ catalog:
dint: ^5.1.0
dir-is-case-sensitive: ^3.0.0
encode-registry: ^3.0.1
enquirer: ^2.4.1
esbuild: ^0.27.7
escape-string-regexp: ^5.0.0
eslint: ^10.4.0

View File

@@ -32,6 +32,7 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.pick-registry-for-package": "workspace:*",
"@pnpm/config.reader": "workspace:*",
@@ -44,7 +45,6 @@
"@pnpm/resolving.registry.types": "workspace:*",
"@pnpm/types": "workspace:*",
"chalk": "catalog:",
"enquirer": "catalog:",
"ramda": "catalog:",
"render-help": "catalog:",
"semver": "catalog:"

View File

@@ -1,5 +1,6 @@
import readline from 'node:readline'
import { input } from '@inquirer/prompts'
import { docsUrl } from '@pnpm/cli.utils'
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import { PnpmError } from '@pnpm/error'
@@ -15,7 +16,6 @@ import {
import npa from '@pnpm/npm-package-arg'
import { setDistTag } from '@pnpm/registry-access.client'
import type { Registries, RegistryConfig } from '@pnpm/types'
import enquirer from 'enquirer'
import { renderHelp } from 'render-help'
import semver from 'semver'
@@ -319,7 +319,7 @@ function createOtpContext (opts: CreateFetchFromRegistryOptions): OtpContext {
return {
Date,
createReadlineInterface: readline.createInterface.bind(null, { input: process.stdin }),
enquirer,
enquirer: { input },
fetch: createFetchFromRegistry(opts),
globalInfo,
globalWarn,

View File

@@ -32,6 +32,7 @@
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest"
},
"dependencies": {
"@inquirer/prompts": "catalog:",
"@pnpm/bins.resolver": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/cli.common-cli-options-help": "workspace:*",
@@ -68,7 +69,6 @@
"chalk": "catalog:",
"ci-info": "catalog:",
"detect-libc": "catalog:",
"enquirer": "catalog:",
"execa": "catalog:",
"libnpmpublish": "catalog:",
"normalize-path": "catalog:",

View File

@@ -1,5 +1,6 @@
import path from 'node:path'
import { confirm } from '@inquirer/prompts'
import { FILTERING } from '@pnpm/cli.common-cli-options-help'
import { docsUrl, readProjectManifest } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
@@ -9,7 +10,6 @@ import { getCurrentBranch, isGitRepo, isRemoteHistoryClean, isWorkingTreeClean }
import type { ExportedManifest } from '@pnpm/releasing.exportable-manifest'
import type { ProjectManifest } from '@pnpm/types'
import { rimraf } from '@zkochan/rimraf'
import enquirer from 'enquirer'
import { pick } from 'ramda'
import { realpathMissing } from 'realpath-missing'
import { renderHelp } from 'render-help'
@@ -188,14 +188,20 @@ export async function publish (
)
}
if (!branches.includes(currentBranch)) {
const { confirm } = await enquirer.prompt({
message: `You're on branch "${currentBranch}" but your "publish-branch" is set to "${branches.join('|')}". \
Do you want to continue?`,
name: 'confirm',
type: 'confirm',
} as any) as any // eslint-disable-line @typescript-eslint/no-explicit-any
let isConfirmed: boolean
try {
isConfirmed = await confirm({
message: `You're on branch "${currentBranch}" but your "publish-branch" is set to "${branches.join('|')}". Do you want to continue?`,
})
} catch (err: unknown) {
if (err instanceof Error && err.name === 'ExitPromptError') {
isConfirmed = false
} else {
throw err
}
}
if (!confirm) {
if (!isConfirmed) {
throw new PnpmError('GIT_NOT_CORRECT_BRANCH', `Branch is not on '${branches.join('|')}'.`, {
hint: GIT_CHECKS_HINT,
})

View File

@@ -1,10 +1,10 @@
import readline from 'node:readline'
import { input } from '@inquirer/prompts'
import { globalInfo, globalWarn } from '@pnpm/logger'
import { fetch } from '@pnpm/network.fetch'
import type { ExportedManifest } from '@pnpm/releasing.exportable-manifest'
import ciInfo from 'ci-info'
import enquirer from 'enquirer'
import { publish as _publish } from 'libnpmpublish'
import type { AuthTokenContext } from '../oidc/authToken.js'
@@ -32,7 +32,7 @@ export const SHARED_CONTEXT: SharedContext = {
Date,
createReadlineInterface: readline.createInterface.bind(null, { input: process.stdin }),
ciInfo,
enquirer,
enquirer: { input },
fetch,
globalInfo,
globalWarn,

View File

@@ -8,11 +8,27 @@ import { temporaryDirectory } from 'tempy'
import { DEFAULT_OPTS } from './utils/index.js'
jest.unstable_mockModule('enquirer', () => ({ default: { prompt: jest.fn() } }))
const { default: enquirer } = await import('enquirer')
jest.unstable_mockModule('@inquirer/prompts', () => {
class Separator {
separator: string
readonly type = 'separator' as const
constructor (separator: string) {
this.separator = separator
}
}
return {
Separator,
checkbox: jest.fn(),
confirm: jest.fn(),
input: jest.fn(),
password: jest.fn(),
select: jest.fn(),
}
})
const { confirm } = await import('@inquirer/prompts')
const { publish } = await import('@pnpm/releasing.commands')
const prompt = jest.mocked(enquirer.prompt)
const mockConfirm = jest.mocked(confirm)
test('publish: fails git check if branch is not on master or main', async () => {
prepare({
@@ -26,9 +42,7 @@ test('publish: fails git check if branch is not on master or main', async () =>
await execa('git', ['add', '*'])
await execa('git', ['commit', '-m', 'init', '--no-gpg-sign'])
prompt.mockResolvedValue({
confirm: false,
})
mockConfirm.mockResolvedValue(false)
await expect(
publish.handler({
@@ -54,9 +68,7 @@ test('publish: fails git check if branch is not on specified branch', async () =
await execa('git', ['add', '*'])
await execa('git', ['commit', '-m', 'init', '--no-gpg-sign'])
prompt.mockResolvedValue({
confirm: false,
})
mockConfirm.mockResolvedValue(false)
await expect(
publish.handler({

View File

@@ -24,7 +24,7 @@ function createMockContext (overrides?: MockContextOverrides): OtpContext {
return {
Date: { now: () => 0 },
setTimeout: (cb: () => void) => cb(),
enquirer: { prompt: async () => ({ otp: '123456' }) },
enquirer: { input: async () => '123456' },
fetch: async () => ({
headers: { get: () => null },
json: async () => ({}),
@@ -95,7 +95,7 @@ describe('publishWithOtpHandling', () => {
expect(opts.otp).toBe('654321')
return createOkResponse()
},
enquirer: { prompt: async () => ({ otp: '654321' }) },
enquirer: { input: async () => '654321' },
})
const result = await publishWithOtpHandling({ context, manifest, publishOptions, tarballData })
expect(result.ok).toBe(true)
@@ -133,7 +133,7 @@ describe('publishWithOtpHandling', () => {
publish: async () => {
throw Object.assign(new Error('otp'), { code: 'EOTP' })
},
enquirer: { prompt: async () => ({ otp: '' }) },
enquirer: { input: async () => '' },
})
await expect(publishWithOtpHandling({ context, manifest, publishOptions, tarballData }))
.rejects.toMatchObject({ code: 'EOTP' })
@@ -144,7 +144,7 @@ describe('publishWithOtpHandling', () => {
publish: async () => {
throw Object.assign(new Error('otp'), { code: 'EOTP' })
},
enquirer: { prompt: async () => undefined },
enquirer: { input: async () => undefined },
})
await expect(publishWithOtpHandling({ context, manifest, publishOptions, tarballData }))
.rejects.toMatchObject({ code: 'EOTP' })