mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-29 03:26:25 -04:00
* 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.
413 lines
13 KiB
TypeScript
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
|
|
}
|