Files
pnpm/patching/commands/src/patch.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

164 lines
4.8 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { docsUrl } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import type { LogBase } from '@pnpm/logger'
import { applyPatchToDir } from '@pnpm/patching.apply-patch'
import type {
CreateStoreControllerOptions,
} from '@pnpm/store.connection-manager'
import chalk from 'chalk'
import isWindows from 'is-windows'
import { pick } from 'ramda'
import { renderHelp } from 'render-help'
import terminalLink from 'terminal-link'
import { getEditDirPath } from './getEditDirPath.js'
import { getPatchedDependency, type GetPatchedDependencyResult } from './getPatchedDependency.js'
import { writeEditDirState } from './stateFile.js'
import { writePackage } from './writePackage.js'
export function rcOptionsTypes (): Record<string, unknown> {
return pick([], allTypes)
}
export function cliOptionsTypes (): Record<string, unknown> {
return { ...rcOptionsTypes(), 'edit-dir': String, 'ignore-existing': Boolean }
}
export const shorthands = {
d: '--edit-dir',
}
export const commandNames = ['patch']
export const recursiveByDefault = true
export function help (): string {
return renderHelp({
description: 'Prepare a package for patching',
descriptionLists: [{
title: 'Options',
list: [
{
description: 'The package that needs to be modified will be extracted to this directory',
name: '--edit-dir',
},
{
description: 'Ignore existing patch files when patching',
name: '--ignore-existing',
},
],
}],
url: docsUrl('patch'),
usages: ['pnpm patch <pkg name>@<version>'],
})
}
export type PatchCommandOptions = Pick<Config,
| 'dir'
| 'patchedDependencies'
| 'registries'
| 'tag'
| 'storeDir'
| 'lockfileDir'
| 'modulesDir'
| 'virtualStoreDir'
| 'sharedWorkspaceLockfile'
> & Pick<ConfigContext,
| 'rootProjectManifest'
> & CreateStoreControllerOptions & {
editDir?: string
reporter?: (logObj: LogBase) => void
ignoreExisting?: boolean
}
export async function handler (opts: PatchCommandOptions, params: string[]): Promise<string> {
if (opts.editDir && fs.existsSync(opts.editDir) && fs.readdirSync(opts.editDir).length > 0) {
throw new PnpmError('PATCH_EDIT_DIR_EXISTS', `The target directory already exists: '${opts.editDir}'`)
}
if (!params[0]) {
throw new PnpmError('MISSING_PACKAGE_NAME', '`pnpm patch` requires the package name')
}
const lockfileDir = opts.lockfileDir ?? opts.dir ?? process.cwd()
const patchedDep = await getPatchedDependency(params[0], {
lockfileDir,
modulesDir: opts.modulesDir,
virtualStoreDir: opts.virtualStoreDir,
})
const quote = isWindows() ? '"' : "'"
const modulesDir = path.join(lockfileDir, opts.modulesDir ?? 'node_modules')
const editDir = opts.editDir
? path.resolve(opts.dir, opts.editDir)
: getEditDirPath(params[0], patchedDep, { modulesDir })
if (fs.existsSync(editDir) && fs.readdirSync(editDir).length !== 0) {
throw new PnpmError('EDIT_DIR_NOT_EMPTY', `The directory ${editDir} is not empty`, {
hint: 'Either run `pnpm patch-commit ' + quote + editDir + quote + '` to commit or delete it then run `pnpm patch` to recreate it',
})
}
await writePackage(patchedDep, editDir, opts)
writeEditDirState({
editDir,
modulesDir,
patchedPkg: params[0],
applyToAll: patchedDep.applyToAll,
})
if (!opts.ignoreExisting && opts.patchedDependencies) {
tryPatchWithExistingPatchFile({
patchedDep,
patchedDir: editDir,
patchedDependencies: opts.patchedDependencies,
lockfileDir,
})
}
return `Patch: You can now edit the package at:
${terminalLink(chalk.blue(editDir), 'file://' + editDir, { fallback: false })}
To commit your changes, run:
${chalk.green(`pnpm patch-commit ${quote}${editDir}${quote}`)}
`
}
function tryPatchWithExistingPatchFile (
{
patchedDep: { applyToAll, alias, bareSpecifier },
patchedDir,
patchedDependencies,
lockfileDir,
}: {
patchedDep: GetPatchedDependencyResult
patchedDir: string
patchedDependencies: Record<string, string>
lockfileDir: string
}
): void {
if (!alias) return
let existingPatchFile: string | undefined
if (bareSpecifier) {
existingPatchFile = patchedDependencies[`${alias}@${bareSpecifier}`]
}
if (!existingPatchFile && applyToAll) {
existingPatchFile = patchedDependencies[alias]
}
if (!existingPatchFile) {
return
}
const existingPatchFilePath = path.resolve(lockfileDir, existingPatchFile)
if (!fs.existsSync(existingPatchFilePath)) {
throw new PnpmError('PATCH_FILE_NOT_FOUND', `Unable to find patch file ${existingPatchFilePath}`)
}
applyPatchToDir({ patchedDir, patchFilePath: existingPatchFilePath })
}