Files
pnpm/exec/lifecycle/src/runLifecycleHook.ts
Zoltan Kochan 96704a1c58 refactor(config): rename rawConfig to authConfig, add nodeDownloadMirrors, simplify config reader (#11194)
Major cleanup of the config system after migrating settings from `.npmrc` to `pnpm-workspace.yaml`.

### Config reader simplification
- Remove `checkUnknownSetting` (dead code, always `false`)
- Trim `npmConfigTypes` from ~127 to ~67 keys (remove unused npm config keys)
- Replace `rcOptions` iteration over all type keys with direct construction from defaults + auth overlay
- Remove `rcOptionsTypes` parameter from `getConfig()` and its assembly chain

### Rename `rawConfig` to `authConfig`
- `rawConfig` was a confusing mix of auth data and general settings
- Non-auth settings are already on the typed `Config` object — stop duplicating them in `rawConfig`
- Rename `rawConfig` → `authConfig` across the codebase to clarify it only contains auth/registry data from `.npmrc`

### Remove `rawConfig` from non-auth consumers
- **Lifecycle hooks**: replace `rawConfig: object` with `userAgent?: string` — only user-agent was read
- **Fetchers**: remove unused `rawConfig` from git fetcher, binary fetcher, tarball fetcher, prepare-package
- **Update command**: use `opts.production/dev/optional` instead of `rawConfig.*`
- **`pnpm init`**: accept typed init properties instead of parsing `rawConfig`

### Add `nodeDownloadMirrors` setting
- New `nodeDownloadMirrors?: Record<string, string>` on `PnpmSettings` and `Config`
- Replaces the `node-mirror:<channel>` pattern that was stored in `rawConfig`
- Configured in `pnpm-workspace.yaml`:
  ```yaml
  nodeDownloadMirrors:
    release: https://my-mirror.example.com/download/release/
  ```
- Remove unused `rawConfig` from deno-resolver and bun-resolver

### Refactor `pnpm config get/list`
- New `configToRecord()` builds display data from typed Config properties on the fly
- Excludes sensitive internals (`authInfos`, `sslConfigs`, etc.)
- Non-types keys (e.g., `package-extensions`) resolve through `configToRecord` instead of direct property access
- Delete `processConfig.ts` (replaced by `configToRecord.ts`)

### Pre-push hook improvement
- Add `compile-only` (`tsgo --build`) to pre-push hook to catch type errors before push
2026-04-04 20:33:43 +02:00

202 lines
6.6 KiB
TypeScript

import { existsSync } from 'node:fs'
import path from 'node:path'
import { lifecycleLogger } from '@pnpm/core-loggers'
import { PnpmError } from '@pnpm/error'
import { globalWarn } from '@pnpm/logger'
import { lifecycle } from '@pnpm/npm-lifecycle'
import type { DependencyManifest, PackageScripts, ProjectManifest } from '@pnpm/types'
import chalk from 'chalk'
import isWindows from 'is-windows'
import { join as shellQuote } from 'shlex'
function noop () {} // eslint-disable-line:no-empty
export interface RunLifecycleHookOptions {
args?: string[]
depPath: string
extraBinPaths?: string[]
extraEnv?: Record<string, string>
initCwd?: string
optional?: boolean
pkgRoot: string
rootModulesDir: string
scriptShell?: string
silent?: boolean
scriptsPrependNodePath?: boolean | 'warn-only'
shellEmulator?: boolean
stdio?: string
unsafePerm: boolean
userAgent?: string
}
export async function runLifecycleHook (
stage: string,
manifest: ProjectManifest | DependencyManifest,
opts: RunLifecycleHookOptions
): Promise<boolean> {
const optional = opts.optional === true
// To remediate CVE_2024_27980, Node.js does not allow .bat or .cmd files to
// be spawned without the "shell: true" option.
//
// https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
//
// Unfortunately, setting spawn's shell option also causes arguments to be
// evaluated before they're passed to the shell, resulting in a surprising
// behavior difference only with .bat/.cmd files.
//
// Instead of showing a "spawn EINVAL" error, let's throw a clearer error that
// this isn't supported.
//
// If this behavior needs to be supported in the future, the arguments would
// need to be escaped before they're passed to the .bat/.cmd file. For
// example, scripts such as "echo %PATH%" should be passed verbatim rather
// than expanded. This is difficult to do correctly. Other open source tools
// (e.g. Rust) attempted and introduced bugs. The Rust blog has a good
// high-level explanation of the same security vulnerability Node.js patched.
//
// https://blog.rust-lang.org/2024/04/09/cve-2024-24576.html#overview
//
// Note that npm (as of version 10.5.0) doesn't support setting script-shell
// to a .bat or .cmd file either.
if (opts.scriptShell != null && typeof opts.scriptShell === 'string' && isWindowsBatchFile(opts.scriptShell)) {
throw new PnpmError('ERR_PNPM_INVALID_SCRIPT_SHELL_WINDOWS', 'Cannot spawn .bat or .cmd as a script shell.', {
hint: `\
The pnpm-workspace.yaml scriptShell option was configured to a .bat or .cmd file. These cannot be used as a script shell reliably.
Please unset the scriptShell option, or configure it to a .exe instead.
`,
})
}
const m = { _id: getId(manifest), ...manifest }
m.scripts = { ...m.scripts }
switch (stage) {
case 'start':
if (!m.scripts.start) {
if (!existsSync('server.js')) {
throw new PnpmError('NO_SCRIPT_OR_SERVER', 'Missing script start or file server.js')
}
m.scripts.start = 'node server.js'
}
break
case 'install':
if (!m.scripts.install && !m.scripts.preinstall) {
checkBindingGyp(opts.pkgRoot, m.scripts)
}
break
}
if (opts.args?.length && m.scripts?.[stage]) {
// It is impossible to quote a command line argument that contains newline for Windows cmd.
const escapedArgs = isWindows()
? opts.args.map((arg) => JSON.stringify(arg)).join(' ')
: shellQuote(opts.args)
m.scripts[stage] = `${m.scripts[stage]} ${escapedArgs}`
}
// This script is used to prevent the usage of npm or Yarn.
// It does nothing, when pnpm is used, so we may skip its execution.
if (m.scripts[stage] === 'npx only-allow pnpm' || !m.scripts[stage]) return false
if (opts.stdio !== 'inherit') {
lifecycleLogger.debug({
depPath: opts.depPath,
optional,
script: m.scripts[stage],
stage,
wd: opts.pkgRoot,
})
} else if (!opts.silent) {
process.stderr.write(chalk.dim(`$ ${m.scripts[stage]}`) + '\n')
}
const logLevel = (opts.stdio !== 'inherit' || opts.silent)
? 'silent'
: undefined
await lifecycle(m, stage, opts.pkgRoot, {
config: {},
dir: opts.rootModulesDir,
extraBinPaths: opts.extraBinPaths,
extraEnv: {
...opts.extraEnv,
INIT_CWD: opts.initCwd ?? process.cwd(),
PNPM_SCRIPT_SRC_DIR: opts.pkgRoot,
...(opts.userAgent ? { npm_config_user_agent: opts.userAgent } : {}),
},
log: {
clearProgress: noop,
info: noop,
level: logLevel,
pause: noop,
resume: noop,
showProgress: noop,
silly: npmLog,
verbose: npmLog,
warn: (...msg: string[]) => {
globalWarn(msg.join(' '))
},
},
runConcurrently: true,
scriptsPrependNodePath: opts.scriptsPrependNodePath,
scriptShell: opts.scriptShell,
shellEmulator: opts.shellEmulator,
stdio: opts.stdio ?? 'pipe',
unsafePerm: opts.unsafePerm,
})
return true
function npmLog (prefix: string, logId: string, stdtype: string, line?: number): void {
switch (stdtype) {
case 'stdout':
case 'stderr':
lifecycleLogger.debug({
depPath: opts.depPath,
line: (line ?? 0).toString(),
stage,
stdio: stdtype,
wd: opts.pkgRoot,
})
return
case 'Returned: code:': {
if (opts.stdio === 'inherit') {
// Preventing the pnpm reporter from overriding the project's script output
return
}
const code = line ?? 1
lifecycleLogger.debug({
depPath: opts.depPath,
exitCode: code,
optional,
stage,
wd: opts.pkgRoot,
})
}
}
}
}
/**
* Run node-gyp when binding.gyp is available. Only do this when there are no
* `install` and `preinstall` scripts (see `npm help scripts`).
*/
function checkBindingGyp (
root: string,
scripts: PackageScripts
) {
if (existsSync(path.join(root, 'binding.gyp'))) {
scripts.install = 'node-gyp rebuild'
}
}
function getId (manifest: ProjectManifest | DependencyManifest): string {
return `${manifest.name ?? ''}@${manifest.version ?? ''}`
}
function isWindowsBatchFile (scriptShell: string) {
// Node.js performs a similar check to determine whether it should throw
// EINVAL when spawning a .cmd/.bat file.
//
// https://github.com/nodejs/node/commit/6627222409#diff-1e725bfa950eda4d4b5c0c00a2bb6be3e5b83d819872a1adf2ef87c658273903
const scriptShellLower = scriptShell.toLowerCase()
return isWindows() && (scriptShellLower.endsWith('.cmd') || scriptShellLower.endsWith('.bat'))
}