Files
pnpm/exec/plugin-commands-script-runners/src/runRecursive.ts
Khải e32b1a29e9 feat: update injected packages after run (#9100)
* feat: update injected packages after run (wip)

close #9081

* refactor: rename field

* feat: injectedPackages (wip)

* feat: findInjectedPackages (wip)

* feat: complete implementation

* test: findInjectedPackages

* docs: changeset

* refactor: be lazy

* chore: set `version` to `1000.0.0-0`

* feat: use hardlinks for injected packages

* refactor: just use `.modules.yaml`

* feat: debug logger

* refactor: `modulesDir` is unnecessary

* test: shouldUpdateInjectedFilesAfterRun

* fix(test): remove the test command

* test: updateInjectedPackagesAfterRun

* fix: eslint

* feat: rename config

* perf: diff to reduce fs operations

* perf: load source map only once

* chore(deps): remove unused dependencies

* fix: eslint

* refactor: use `symlink-dir`

* refactor: move type expr to an alias

* refactor: simplify types

* feat: reuse stats from the directory fetcher

* test: directories and symlinks

* feat: sort alphabetic

* test: diffDir

* test: rename a test

* test: remove nesting

* refactor: rename

* feat: remove buggy symlink support

* test: applyPatch

* docs: correct

* docs: fix

* test: extendFilesMap

* docs: remove outdated comment

* docs: remove unneeded comment

* test: fix

* test: more assertions

* test: DirPatcher

* test: more assertions

* test: more assertions

* test: just use `createDir`

* test: multiple patchers

* test: reuse stat results

* docs: consistent grammar

* test: workaround

* test: fix windows

* refactor: remove single-use `makeParent`

* refactor: remove nonsense test

How could I even misunderstand my own code?!

`Patcher.apply()` will never call stat on the files because they have all
been loaded to calculate `Patcher.patch`.

This test is therefore nonsense.

* feat: rename

* feat: rename again

* feat: remove `boolean`

* fix: broken lockfile

* test: use a fixture for testing sync injected deps

* test: refactor syne injected deps test

* test: refactor sync injected deps test

* test: refactor sync injected deps test

* refactor: rename injected deps to syncer

* refactor: change injected deps logger

* docs: update changeset

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2025-02-24 02:09:45 +01:00

237 lines
8.3 KiB
TypeScript

import assert from 'assert'
import path from 'path'
import util from 'util'
import { throwOnCommandFail } from '@pnpm/cli-utils'
import { type Config } from '@pnpm/config'
import { prepareExecutionEnv } from '@pnpm/plugin-commands-env'
import { PnpmError } from '@pnpm/error'
import {
makeNodeRequireOption,
type RunLifecycleHookOptions,
} from '@pnpm/lifecycle'
import { logger } from '@pnpm/logger'
import { groupStart } from '@pnpm/log.group'
import { sortPackages } from '@pnpm/sort-packages'
import pLimit from 'p-limit'
import realpathMissing from 'realpath-missing'
import { existsInDir } from './existsInDir'
import { createEmptyRecursiveSummary, getExecutionDuration, getResumedPackageChunks, writeRecursiveSummary } from './exec'
import { type RunScriptOptions, runScript } from './run'
import { tryBuildRegExpFromCommand } from './regexpCommand'
import { type PackageScripts, type ProjectRootDir } from '@pnpm/types'
export type RecursiveRunOpts = Pick<Config,
| 'bin'
| 'enablePrePostScripts'
| 'unsafePerm'
| 'pnpmHomeDir'
| 'rawConfig'
| 'rootProjectManifest'
| 'scriptsPrependNodePath'
| 'scriptShell'
| 'shellEmulator'
| 'stream'
| 'syncInjectedDepsAfterScripts'
| 'workspaceDir'
> & Required<Pick<Config, 'allProjects' | 'selectedProjectsGraph' | 'workspaceDir' | 'dir'>> &
Partial<Pick<Config, 'extraBinPaths' | 'extraEnv' | 'bail' | 'reporter' | 'reverse' | 'sort' | 'workspaceConcurrency'>> &
{
ifPresent?: boolean
resumeFrom?: string
reportSummary?: boolean
}
export async function runRecursive (
params: string[],
opts: RecursiveRunOpts
): Promise<void> {
const [scriptName, ...passedThruArgs] = params
if (!scriptName) {
throw new PnpmError('SCRIPT_NAME_IS_REQUIRED', 'You must specify the script you want to run')
}
let hasCommand = 0
const sortedPackageChunks = opts.sort
? sortPackages(opts.selectedProjectsGraph)
: [(Object.keys(opts.selectedProjectsGraph) as ProjectRootDir[]).sort()]
let packageChunks: ProjectRootDir[][] = opts.reverse ? sortedPackageChunks.reverse() : sortedPackageChunks
if (opts.resumeFrom) {
packageChunks = getResumedPackageChunks({
resumeFrom: opts.resumeFrom,
chunks: packageChunks,
selectedProjectsGraph: opts.selectedProjectsGraph,
})
}
const limitRun = pLimit(opts.workspaceConcurrency ?? 4)
const stdio =
!opts.stream &&
(opts.workspaceConcurrency === 1 ||
(packageChunks.length === 1 && packageChunks[0].length === 1))
? 'inherit'
: 'pipe'
const existsPnp = existsInDir.bind(null, '.pnp.cjs')
const workspacePnpPath = opts.workspaceDir && existsPnp(opts.workspaceDir)
const requiredScripts = opts.rootProjectManifest?.pnpm?.requiredScripts ?? []
if (requiredScripts.includes(scriptName)) {
const missingScriptPackages: string[] = packageChunks
.flat()
.map((prefix) => opts.selectedProjectsGraph[prefix])
.filter((pkg) => getSpecifiedScripts(pkg.package.manifest.scripts ?? {}, scriptName).length < 1)
.map((pkg) => pkg.package.manifest.name ?? pkg.package.rootDir)
if (missingScriptPackages.length) {
throw new PnpmError('RECURSIVE_RUN_NO_SCRIPT', `Missing script "${scriptName}" in packages: ${missingScriptPackages.join(', ')}`)
}
}
const result = createEmptyRecursiveSummary(packageChunks)
for (const chunk of packageChunks) {
const selectedScripts = chunk.map(prefix => {
const pkg = opts.selectedProjectsGraph[prefix]
const specifiedScripts = getSpecifiedScripts(pkg.package.manifest.scripts ?? {}, scriptName)
if (!specifiedScripts.length) {
result[prefix].status = 'skipped'
}
return specifiedScripts.map(script => ({ prefix, scriptName: script }))
}).flat()
// eslint-disable-next-line no-await-in-loop
await Promise.all(selectedScripts.map(async ({ prefix, scriptName }) =>
limitRun(async () => {
const pkg = opts.selectedProjectsGraph[prefix]
if (
!pkg.package.manifest.scripts?.[scriptName] ||
process.env.npm_lifecycle_event === scriptName &&
process.env.PNPM_SCRIPT_SRC_DIR === prefix
) {
return
}
result[prefix].status = 'running'
const startTime = process.hrtime()
hasCommand++
try {
const lifecycleOpts: RunLifecycleHookOptions = {
depPath: prefix,
extraBinPaths: opts.extraBinPaths,
extraEnv: opts.extraEnv,
pkgRoot: prefix,
rawConfig: opts.rawConfig,
rootModulesDir: await realpathMissing(path.join(prefix, 'node_modules')),
scriptsPrependNodePath: opts.scriptsPrependNodePath,
scriptShell: opts.scriptShell,
silent: opts.reporter === 'silent',
shellEmulator: opts.shellEmulator,
stdio,
unsafePerm: true, // when running scripts explicitly, assume that they're trusted.
}
const { executionEnv } = pkg.package.manifest.pnpm ?? {}
if (executionEnv != null) {
lifecycleOpts.extraBinPaths = (await prepareExecutionEnv(opts, { executionEnv })).extraBinPaths
}
const pnpPath = workspacePnpPath ?? existsPnp(prefix)
if (pnpPath) {
lifecycleOpts.extraEnv = {
...lifecycleOpts.extraEnv,
...makeNodeRequireOption(pnpPath),
}
}
const runScriptOptions: RunScriptOptions = {
enablePrePostScripts: opts.enablePrePostScripts ?? false,
syncInjectedDepsAfterScripts: opts.syncInjectedDepsAfterScripts,
workspaceDir: opts.workspaceDir,
}
const _runScript = runScript.bind(null, { manifest: pkg.package.manifest, lifecycleOpts, runScriptOptions, passedThruArgs })
const groupEnd = (opts.workspaceConcurrency ?? 4) > 1
? undefined
: groupStart(formatSectionName({
name: pkg.package.manifest.name,
script: scriptName,
version: pkg.package.manifest.version,
prefix: path.normalize(path.relative(opts.workspaceDir, prefix)),
}))
await _runScript(scriptName)
groupEnd?.()
result[prefix].status = 'passed'
result[prefix].duration = getExecutionDuration(startTime)
} catch (err: unknown) {
assert(util.types.isNativeError(err))
result[prefix] = {
status: 'failure',
duration: getExecutionDuration(startTime),
error: err,
message: err.message,
prefix,
}
if (!opts.bail) {
return
}
Object.assign(err, {
code: 'ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL',
prefix,
})
opts.reportSummary && await writeRecursiveSummary({
dir: opts.workspaceDir ?? opts.dir,
summary: result,
})
throw err
}
}
)))
}
if (scriptName !== 'test' && !hasCommand && !opts.ifPresent) {
const allPackagesAreSelected = Object.keys(opts.selectedProjectsGraph).length === opts.allProjects.length
if (allPackagesAreSelected) {
throw new PnpmError('RECURSIVE_RUN_NO_SCRIPT', `None of the packages has a "${scriptName}" script`)
} else {
logger.info({
message: `None of the selected packages has a "${scriptName}" script`,
prefix: opts.workspaceDir,
})
}
}
opts.reportSummary && await writeRecursiveSummary({
dir: opts.workspaceDir ?? opts.dir,
summary: result,
})
throwOnCommandFail('pnpm recursive run', result)
}
function formatSectionName ({
script,
name,
version,
prefix,
}: {
script?: string
name?: string
version?: string
prefix: string
}) {
return `${name ?? 'unknown'}${version ? `@${version}` : ''} ${script ? `: ${script}` : ''} ${prefix}`
}
export function getSpecifiedScripts (scripts: PackageScripts, scriptName: string): string[] {
// if scripts in package.json has script which is equal to scriptName a user passes, return it.
if (scripts[scriptName]) {
return [scriptName]
}
const scriptSelector = tryBuildRegExpFromCommand(scriptName)
// if scriptName which a user passes is RegExp (like /build:.*/), multiple scripts to execute will be selected with RegExp
if (scriptSelector) {
const scriptKeys = Object.keys(scripts)
return scriptKeys.filter(script => script.match(scriptSelector))
}
return []
}