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 = { parallel: runShorthands.parallel, c: '--shell-mode', } export const commandNames = ['exec'] export function rcOptionsTypes (): Record { 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 => ({ ...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 [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 { 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> & { bail?: boolean unsafePerm?: boolean reverse?: boolean sort?: boolean workspaceConcurrency?: number shellMode?: boolean resumeFrom?: string reportSummary?: boolean implicitlyFellbackFromRun?: boolean } & Pick & Pick & 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 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((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 { 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 ` 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 }