Files
pnpm/exec/commands/src/exec.ts
Zoltan Kochan 3033bee430 refactor(config): split Config interface into settings + runtime context (#11197)
* refactor(config): split Config interface into settings + runtime context

Create ConfigContext for runtime state (hooks, finders, workspace graph,
CLI metadata) and keep Config for user-facing settings only. Functions
use Pick<Config, ...> & Pick<ConfigContext, ...> to express which fields
they need from each interface.

getConfig() now returns { config, context, warnings }. The CLI wrapper
returns { config, context } and spreads both when calling command
handlers (to be refactored to separate params in follow-up PRs).

Closes #11195

* fix: address review feedback

- Initialize cliOptions on pnpmConfig so context.cliOptions is never undefined
- Move rootProjectManifestDir assignment before ignoreLocalSettings guard
- Add allProjectsGraph to INTERNAL_CONFIG_KEYS

* refactor: remove INTERNAL_CONFIG_KEYS from configToRecord

configToRecord now accepts Config and ConfigContext separately, so
context fields are never in scope. Only auth-related Config fields
(authConfig, authInfos, sslConfigs) need filtering.

* refactor: eliminate INTERNAL_CONFIG_KEYS from configToRecord

configToRecord now receives the clean Config object and explicitlySetKeys
separately (via opts.config and opts.context), so context fields are
never in scope. main.ts passes the original split objects alongside
the spread for command handlers that need them.

* fix: spelling

* fix: import sorting

* fix: --config.xxx nconf overrides conflicting with --config CLI flag

When `pnpm add` registers `config: Boolean`, nopt captures
--config.xxx=yyy as the --config flag value instead of treating it
as a nconf-style config override. Fix by extracting --config.xxx args
before nopt parsing and re-parsing them separately.

Also rename the split config/context properties on the command opts
object to _config/_context to avoid clashing with the --config CLI option.
2026-04-04 23:44:25 +02:00

413 lines
13 KiB
TypeScript

import path from 'node:path'
import { FILTERING, UNIVERSAL_OPTIONS } from '@pnpm/cli.common-cli-options-help'
import { docsUrl, readProjectManifestOnly, type RecursiveSummary, throwOnCommandFail } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, getWorkspaceConcurrency, types } from '@pnpm/config.reader'
import { lifecycleLogger, type LifecycleMessage } from '@pnpm/core-loggers'
import type { CheckDepsStatusOptions } from '@pnpm/deps.status'
import { PnpmError } from '@pnpm/error'
import { makeNodeRequireOption } from '@pnpm/exec.lifecycle'
import { logger } from '@pnpm/logger'
import { prependDirsToPath } from '@pnpm/shell.path'
import type { Project, ProjectRootDir, ProjectRootDirRealPath, ProjectsGraph } from '@pnpm/types'
import { tryReadProjectManifest } from '@pnpm/workspace.project-manifest-reader'
import { sortProjects } from '@pnpm/workspace.projects-sorter'
import { safeExeca as execa } from 'execa'
import pLimit from 'p-limit'
import { pick } from 'ramda'
import { renderHelp } from 'render-help'
import which from 'which'
import { writeJsonFile } from 'write-json-file'
import { getNearestProgram, getNearestScript } from './buildCommandNotFoundHint.js'
import { existsInDir } from './existsInDir.js'
import { makeEnv } from './makeEnv.js'
import {
PARALLEL_OPTION_HELP,
REPORT_SUMMARY_OPTION_HELP,
RESUME_FROM_OPTION_HELP,
shorthands as runShorthands,
} from './run.js'
import { runDepsStatusCheck } from './runDepsStatusCheck.js'
export const shorthands: Record<string, string | string[]> = {
parallel: runShorthands.parallel,
c: '--shell-mode',
}
export const commandNames = ['exec']
export function rcOptionsTypes (): Record<string, unknown> {
return {
...pick([
'bail',
'sort',
'unsafe-perm',
'workspace-concurrency',
'reporter-hide-prefix',
], types),
'shell-mode': Boolean,
'resume-from': String,
'report-summary': Boolean,
}
}
export const cliOptionsTypes = (): Record<string, unknown> => ({
...rcOptionsTypes(),
recursive: Boolean,
reverse: Boolean,
})
export function help (): string {
return renderHelp({
description: 'Run a shell command in the context of a project.',
descriptionLists: [
{
title: 'Options',
list: [
{
description: 'Do not hide project name prefix from output of recursively running command.',
name: '--no-reporter-hide-prefix',
},
PARALLEL_OPTION_HELP,
{
description: 'Run the shell command in every package found in subdirectories \
or every workspace package, when executed inside a workspace. \
For options that may be used with `-r`, see "pnpm help recursive"',
name: '--recursive',
shortAlias: '-r',
},
{
description: 'If exist, runs file inside of a shell. \
Uses /bin/sh on UNIX and \\cmd.exe on Windows. \
The shell should understand the -c switch on UNIX or /d /s /c on Windows.',
name: '--shell-mode',
shortAlias: '-c',
},
RESUME_FROM_OPTION_HELP,
REPORT_SUMMARY_OPTION_HELP,
...UNIVERSAL_OPTIONS,
],
},
FILTERING,
],
url: docsUrl('exec'),
usages: ['pnpm [-r] [-c] exec <command> [args...]'],
})
}
export function getResumedPackageChunks ({
resumeFrom,
chunks,
selectedProjectsGraph,
}: {
resumeFrom: string
chunks: ProjectRootDir[][]
selectedProjectsGraph: ProjectsGraph
}): ProjectRootDir[][] {
const resumeFromPackagePrefix = (Object.keys(selectedProjectsGraph) as ProjectRootDir[])
.find((prefix) => selectedProjectsGraph[prefix]?.package.manifest.name === resumeFrom)
if (!resumeFromPackagePrefix) {
throw new PnpmError('RESUME_FROM_NOT_FOUND', `Cannot find package ${resumeFrom}. Could not determine where to resume from.`)
}
const chunkPosition = chunks.findIndex(chunk => chunk.includes(resumeFromPackagePrefix))
return chunks.slice(chunkPosition)
}
export async function writeRecursiveSummary (opts: { dir: string, summary: RecursiveSummary }): Promise<void> {
await writeJsonFile(path.join(opts.dir, 'pnpm-exec-summary.json'), {
executionStatus: opts.summary,
})
}
export function createEmptyRecursiveSummary (chunks: string[][]): RecursiveSummary {
const acc: RecursiveSummary = {}
for (const prefix of chunks.flat()) {
acc[prefix] = { status: 'queued' }
}
return acc
}
export function getExecutionDuration (start: [number, number]): number {
const end = process.hrtime(start)
return (end[0] * 1e9 + end[1]) / 1e6
}
export type ExecOpts = Required<Pick<ConfigContext, 'selectedProjectsGraph'>> & {
bail?: boolean
unsafePerm?: boolean
reverse?: boolean
sort?: boolean
workspaceConcurrency?: number
shellMode?: boolean
resumeFrom?: string
reportSummary?: boolean
implicitlyFellbackFromRun?: boolean
} & Pick<Config,
| 'bin'
| 'dir'
| 'extraBinPaths'
| 'extraEnv'
| 'lockfileDir'
| 'modulesDir'
| 'nodeOptions'
| 'pnpmHomeDir'
| 'recursive'
| 'reporterHidePrefix'
| 'userAgent'
| 'verifyDepsBeforeRun'
| 'workspaceDir'
> & Pick<ConfigContext, 'cliOptions'> & CheckDepsStatusOptions
export async function handler (
opts: ExecOpts,
params: string[]
): Promise<{ exitCode: number }> {
// For backward compatibility
if (params[0] === '--') {
params.shift()
}
if (!params[0]) {
throw new PnpmError('EXEC_MISSING_COMMAND', '\'pnpm exec\' requires a command to run')
}
const limitRun = pLimit(getWorkspaceConcurrency(opts.workspaceConcurrency))
if (opts.verifyDepsBeforeRun) {
await runDepsStatusCheck(opts)
}
let chunks!: ProjectRootDir[][]
if (opts.recursive) {
chunks = opts.sort
? sortProjects(opts.selectedProjectsGraph)
: [(Object.keys(opts.selectedProjectsGraph) as ProjectRootDir[]).sort()]
if (opts.reverse) {
chunks = chunks.reverse()
}
} else {
chunks = [[(opts.cliOptions.dir ?? process.cwd()) as ProjectRootDir]]
const project = await tryReadProjectManifest(opts.dir)
if (project.manifest != null) {
opts.selectedProjectsGraph = {
[opts.dir]: {
dependencies: [],
package: {
...project,
rootDir: opts.dir as ProjectRootDir,
rootDirRealPath: opts.dir as ProjectRootDirRealPath,
} as Project,
},
}
}
}
if (!opts.selectedProjectsGraph) {
throw new PnpmError('RECURSIVE_EXEC_NO_PACKAGE', 'No package found in this workspace')
}
if (opts.resumeFrom) {
chunks = getResumedPackageChunks({
resumeFrom: opts.resumeFrom,
chunks,
selectedProjectsGraph: opts.selectedProjectsGraph,
})
}
const result = createEmptyRecursiveSummary(chunks)
const existsPnp = existsInDir.bind(null, '.pnp.cjs')
const workspacePnpPath = opts.workspaceDir && existsPnp(opts.workspaceDir)
let exitCode = 0
const prependPaths = [
'./node_modules/.bin',
...(opts.extraBinPaths ?? []),
]
const reporterShowPrefix = opts.recursive && opts.reporterHidePrefix === false
for (const chunk of chunks) {
// eslint-disable-next-line no-await-in-loop
await Promise.all(chunk.map(async (prefix) =>
limitRun(async () => {
result[prefix].status = 'running'
const startTime = process.hrtime()
try {
const pnpPath = workspacePnpPath ?? existsPnp(prefix)
const extraEnv = {
...opts.extraEnv,
...(pnpPath ? makeNodeRequireOption(pnpPath) : {}),
}
const env = makeEnv({
extraEnv: {
...extraEnv,
PNPM_PACKAGE_NAME: opts.selectedProjectsGraph[prefix]?.package.manifest.name,
...(opts.nodeOptions ? { NODE_OPTIONS: opts.nodeOptions } : {}),
},
prependPaths,
userAgent: opts.userAgent,
})
const [cmd, ...args] = params
if (reporterShowPrefix) {
const manifest = await readProjectManifestOnly(prefix)
const child = execa(cmd, args, {
cwd: prefix,
env,
stdio: 'pipe',
shell: opts.shellMode ?? false,
})
const lifecycleOpts = {
wd: prefix,
depPath: manifest.name ?? path.relative(opts.dir, prefix),
stage: '(exec)',
} satisfies Partial<LifecycleMessage>
const logFn = (stdio: 'stdout' | 'stderr') => (data: unknown): void => {
for (const line of String(data).split('\n')) {
lifecycleLogger.debug({
...lifecycleOpts,
stdio,
line,
})
}
}
child.stdout!.on('data', logFn('stdout'))
child.stderr!.on('data', logFn('stderr'))
await new Promise<void>((resolve) => {
void child.once('close', exitCode => {
lifecycleLogger.debug({
...lifecycleOpts,
exitCode: exitCode ?? 1,
optional: false,
})
resolve()
})
})
await child
} else {
await execa(cmd, args, {
cwd: prefix,
env,
stdio: 'inherit',
shell: opts.shellMode ?? false,
})
}
result[prefix].status = 'passed'
result[prefix].duration = getExecutionDuration(startTime)
} catch (err: any) { // eslint-disable-line
if (isErrorCommandNotFound(params[0], err, prefix, prependPaths)) {
err.message = `Command "${params[0]}" not found`
err.hint = await createExecCommandNotFoundHint(params[0], {
implicitlyFellbackFromRun: opts.implicitlyFellbackFromRun ?? false,
dir: opts.dir,
workspaceDir: opts.workspaceDir,
modulesDir: opts.modulesDir ?? 'node_modules',
})
} else if (!opts.recursive && typeof err.exitCode === 'number') {
exitCode = err.exitCode
return
}
logger.info(err)
result[prefix] = {
status: 'failure',
duration: getExecutionDuration(startTime),
error: err,
message: err.message,
prefix,
}
if (!opts.bail) {
return
}
if (!err['code']?.startsWith('ERR_PNPM_')) {
err['code'] = 'ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL'
}
err['prefix'] = prefix
if (opts.reportSummary) {
await writeRecursiveSummary({
dir: opts.lockfileDir ?? opts.dir,
summary: result,
})
}
throw err
}
}
)))
}
if (opts.reportSummary) {
await writeRecursiveSummary({
dir: opts.lockfileDir ?? opts.dir,
summary: result,
})
}
throwOnCommandFail('pnpm recursive exec', result)
return { exitCode }
}
async function createExecCommandNotFoundHint (
programName: string,
opts: {
dir: string
implicitlyFellbackFromRun: boolean
workspaceDir?: string
modulesDir: string
}
): Promise<string | undefined> {
if (opts.implicitlyFellbackFromRun) {
let nearestScript: string | null | undefined
try {
nearestScript = getNearestScript(programName, (await readProjectManifestOnly(opts.dir)).scripts)
} catch {}
if (nearestScript) {
return `Did you mean "pnpm ${nearestScript}"?`
}
const nearestProgram = getNearestProgram({
programName,
dir: opts.dir,
workspaceDir: opts.workspaceDir,
modulesDir: opts.modulesDir,
})
if (nearestProgram) {
return `Did you mean "pnpm ${nearestProgram}"?`
}
return undefined
}
const nearestProgram = getNearestProgram({
programName,
dir: opts.dir,
workspaceDir: opts.workspaceDir,
modulesDir: opts.modulesDir,
})
if (nearestProgram) {
return `Did you mean "pnpm exec ${nearestProgram}"?`
}
return undefined
}
interface CommandError extends Error {
originalMessage: string
shortMessage: string
}
function isErrorCommandNotFound (command: string, error: CommandError, prefix: string, prependPaths: string[]): boolean {
if (error.originalMessage === `spawn ${command} ENOENT`) {
return true
}
// On Windows, execa 9.x uses cross-spawn only for command parsing (not spawning),
// so cross-spawn's ENOENT hook never fires. Non-existent commands get wrapped as
// `cmd.exe /c <command>` which exits with code 1 instead of emitting ENOENT.
// Fall back to checking if the command exists in PATH, resolving relative paths
// against the exec prefix to correctly handle --filter contexts.
if (process.platform === 'win32') {
const absolutePrependPaths = prependPaths.map(p => path.resolve(prefix, p))
const { value: searchPath } = prependDirsToPath(absolutePrependPaths)
return !which.sync(command, { nothrow: true, path: searchPath })
}
return false
}