import { type Stats } from 'fs' import fs from 'fs/promises' import path from 'path' import { docsUrl } from '@pnpm/cli-utils' import { install } from '@pnpm/plugin-commands-installation' import { type Config, types as allTypes } from '@pnpm/config' import { PnpmError } from '@pnpm/error' import renderHelp from 'render-help' import { prompt } from 'enquirer' import pick from 'ramda/src/pick' import { isSubdirectory } from './isSubdirectory.js' import { updatePatchedDependencies } from './updatePatchedDependencies.js' export function rcOptionsTypes (): Record { return pick([], allTypes) } export function cliOptionsTypes (): Record { return { ...rcOptionsTypes() } } export const commandNames = ['patch-remove'] export function help (): string { return renderHelp({ description: 'Remove existing patch files', url: docsUrl('patch-remove'), usages: ['pnpm patch-remove [pkg...]'], }) } export type PatchRemoveCommandOptions = install.InstallCommandOptions & Pick export async function handler (opts: PatchRemoveCommandOptions, params: string[]): Promise { let patchesToRemove = params const patchedDependencies = { ...opts.patchedDependencies } if (!params.length) { const allPatches = Object.keys(patchedDependencies) if (allPatches.length) { ({ patches: patchesToRemove } = await prompt<{ patches: string[] }>({ type: 'multiselect', name: 'patches', message: 'Select the patch to be removed', choices: allPatches, validate (value) { return value.length === 0 ? 'Select at least one option.' : true }, })) } } if (!patchesToRemove.length) { throw new PnpmError('NO_PATCHES_TO_REMOVE', 'There are no patches that need to be removed') } for (const patch of patchesToRemove) { if (!Object.hasOwn(patchedDependencies, patch)) { throw new PnpmError('PATCH_NOT_FOUND', `Patch "${patch}" not found in patched dependencies`) } } const patchRemovalContext = await getPatchRemovalContext(opts) const patchesToRemoveTargets = await Promise.all(patchesToRemove.map(async (patch) => { const patchFile = patchedDependencies[patch] if (patchFile == null) { throw new PnpmError('PATCH_NOT_FOUND', `Patch "${patch}" not found in patched dependencies`) } return getPatchRemovalTarget(patch, patchFile, patchRemovalContext) })) await Promise.all(patchesToRemoveTargets.map(unlinkPatchIfExists)) for (const { patch } of patchesToRemoveTargets) { delete patchedDependencies[patch] } const patchesDirs = new Set(patchesToRemoveTargets.map(({ parentDir }) => parentDir)) await Promise.all(Array.from(patchesDirs).map(async (dir) => { try { const files = await fs.readdir(dir) if (!files.length) { await fs.rmdir(dir) } } catch {} })) await updatePatchedDependencies(patchedDependencies, { ...opts, workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir, }) return install.handler({ ...opts, patchedDependencies, }) } interface PatchRemovalContext { lockfileDir: string patchesDir: string realPatchesDir?: string } interface PatchRemovalTarget { patch: string patchFile: string parentDir: string targetPath: string targetExists: boolean } async function getPatchRemovalContext (opts: PatchRemoveCommandOptions): Promise { const lockfileDir = path.resolve(opts.lockfileDir ?? opts.dir ?? process.cwd()) const realLockfileDir = await fs.realpath(lockfileDir) const patchesDirSetting = opts.patchesDir ?? 'patches' const patchesDir = path.join(lockfileDir, path.normalize(patchesDirSetting)) if (!isSubdirectory(lockfileDir, patchesDir)) { throw new PnpmError('PATCHES_DIR_OUTSIDE_PROJECT', `The configured patches directory is outside the project: ${patchesDirSetting}`) } const realPatchesDir = await realpathIfExists(patchesDir) if (realPatchesDir != null && !isSubdirectory(realLockfileDir, realPatchesDir)) { throw new PnpmError('PATCHES_DIR_OUTSIDE_PROJECT', `The configured patches directory is outside the project: ${patchesDirSetting}`) } return { lockfileDir, patchesDir, realPatchesDir, } } async function getPatchRemovalTarget ( patch: string, patchFile: string, ctx: PatchRemovalContext ): Promise { const targetPath = path.resolve(ctx.lockfileDir, patchFile) if ( targetPath === ctx.patchesDir || !isSubdirectory(ctx.patchesDir, targetPath) ) { throw new PnpmError('PATCH_FILE_OUTSIDE_PATCHES_DIR', `Patch file "${patchFile}" is outside the configured patches directory`) } const parentDir = path.dirname(targetPath) const targetStats = await lstatIfExists(targetPath) const realParentDir = await realpathIfExists(parentDir) const realPatchesDir = ctx.realPatchesDir ?? (await realpathIfExists(ctx.patchesDir)) if ( realParentDir != null && realPatchesDir != null && !isSubdirectory(realPatchesDir, realParentDir) ) { throw new PnpmError('PATCH_FILE_OUTSIDE_PATCHES_DIR', `Patch file "${patchFile}" is outside the configured patches directory`) } if (targetStats?.isDirectory()) { throw new PnpmError('PATCH_FILE_IS_DIRECTORY', `Patch file "${patchFile}" is a directory`) } return { patch, patchFile, parentDir, targetPath, targetExists: targetStats != null, } } async function unlinkPatchIfExists ({ targetExists, targetPath }: PatchRemovalTarget): Promise { if (!targetExists) return try { await fs.unlink(targetPath) } catch (err: unknown) { if (isErrorWithCode(err, 'ENOENT')) return throw err } } async function lstatIfExists (targetPath: string): Promise { try { return await fs.lstat(targetPath) } catch (err: unknown) { if (isErrorWithCode(err, 'ENOENT')) return undefined throw err } } async function realpathIfExists (targetPath: string): Promise { try { return await fs.realpath(targetPath) } catch (err: unknown) { if (isErrorWithCode(err, 'ENOENT')) return undefined throw err } } function isErrorWithCode (err: unknown, code: string): err is NodeJS.ErrnoException { return typeof err === 'object' && err !== null && 'code' in err && err.code === code }