feat: prefer-symlinked-executables (#5048)

A new setting supported: `prefer-symlinked-executables`
When `true`, on Posix systems pnpm will create symlinks to executables in
`node_modules/.bin` instead of command shims.

This setting is `true` by default when `node-linker` is set to
`hoisted`.

close #4782
This commit is contained in:
Zoltan Kochan
2022-07-18 17:18:31 +03:00
committed by GitHub
parent af79b6184c
commit 28f0005096
16 changed files with 137 additions and 16 deletions

View File

@@ -0,0 +1,16 @@
---
"@pnpm/build-modules": minor
"@pnpm/config": minor
"@pnpm/core": minor
"@pnpm/headless": minor
"@pnpm/hoist": minor
"@pnpm/link-bins": minor
"pnpm": minor
---
A new setting supported: `prefer-symlinked-executables`. When `true`, pnpm will create symlinks to executables in
`node_modules/.bin` instead of command shims (but on POSIX systems only).
This setting is `true` by default when `node-linker` is set to `hoisted`.
Related issue: [#4782](https://github.com/pnpm/pnpm/issues/4782).

View File

@@ -27,6 +27,7 @@ export default async (
ignoreScripts?: boolean
lockfileDir: string
optional: boolean
preferSymlinkedExecutables?: boolean
rawConfig: object
unsafePerm: boolean
userAgent: string
@@ -70,6 +71,7 @@ async function buildDependency (
ignoreScripts?: boolean
lockfileDir: string
optional: boolean
preferSymlinkedExecutables?: boolean
rawConfig: object
rootModulesDir: string
scriptsPrependNodePath?: boolean | 'warn-only'
@@ -168,6 +170,7 @@ export async function linkBinsOfDependencies (
opts: {
extraNodePaths?: string[]
optional: boolean
preferSymlinkedExecutables?: boolean
warn: (message: string) => void
}
) {
@@ -204,11 +207,18 @@ export async function linkBinsOfDependencies (
}))
)
await linkBinsOfPackages(pkgs, binPath, { extraNodePaths: opts.extraNodePaths })
await linkBinsOfPackages(pkgs, binPath, {
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
})
// link also the bundled dependencies` bins
if (depNode.hasBundledDependencies) {
const bundledModules = path.join(depNode.dir, 'node_modules')
await linkBins(bundledModules, binPath, { extraNodePaths: opts.extraNodePaths, warn: opts.warn })
await linkBins(bundledModules, binPath, {
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn: opts.warn,
})
}
}

View File

@@ -78,6 +78,7 @@ export interface Config {
useNodeVersion?: string
useStderr?: boolean
nodeLinker?: 'hoisted' | 'isolated' | 'pnp'
preferSymlinkedExecutables?: boolean
// proxy
httpProxy?: string

View File

@@ -83,6 +83,7 @@ export const types = Object.assign({
pnpmfile: String,
'prefer-frozen-lockfile': Boolean,
'prefer-offline': Boolean,
'prefer-symlinked-executables': Boolean,
'prefer-workspace-packages': Boolean,
production: [null, true],
'public-hoist-pattern': Array,
@@ -458,7 +459,16 @@ export default async (
if (!pnpmConfig.noProxy) {
pnpmConfig.noProxy = pnpmConfig['noproxy'] ?? getProcessEnv('no_proxy')
}
pnpmConfig.enablePnp = pnpmConfig.nodeLinker === 'pnp'
switch (pnpmConfig.nodeLinker) {
case 'pnp':
pnpmConfig.enablePnp = pnpmConfig.nodeLinker === 'pnp'
break
case 'hoisted':
if (pnpmConfig.preferSymlinkedExecutables == null) {
pnpmConfig.preferSymlinkedExecutables = true
}
break
}
if (!pnpmConfig.userConfig) {
pnpmConfig.userConfig = npmConfig.sources.user?.data
}

View File

@@ -897,3 +897,18 @@ test('getConfig() sets merge-git-branch-lockfiles when branch matches merge-git-
expect(config.mergeGitBranchLockfiles).toBe(true)
}
})
test('preferSymlinkedExecutables should be true when nodeLinker is hoisted', async () => {
prepareEmpty()
const { config } = await getConfig({
cliOptions: {
'node-linker': 'hoisted',
},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.preferSymlinkedExecutables).toBeTruthy()
})

View File

@@ -90,6 +90,7 @@ export interface StrictInstallOptions {
modulesCacheMaxAge: number
peerDependencyRules: PeerDependencyRules
allowedDeprecatedVersions: AllowedDeprecatedVersions
preferSymlinkedExecutables: boolean
publicHoistPattern: string[] | undefined
hoistPattern: string[] | undefined

View File

@@ -937,6 +937,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
ignoreScripts: opts.ignoreScripts,
lockfileDir: ctx.lockfileDir,
optional: opts.include.optionalDependencies,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
rawConfig: opts.rawConfig,
rootModulesDir: ctx.virtualStoreDir,
scriptsPrependNodePath: opts.scriptsPrependNodePath,
@@ -972,6 +973,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
}, {})
linkedPackages = await linkBins(project.modulesDir, project.binsDir, {
allowExoticManifests: true,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
projectManifest: project.manifest,
nodeExecPathByAlias,
extraNodePaths: ctx.extraNodePaths,
@@ -1009,6 +1011,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
project.binsDir,
{
extraNodePaths: ctx.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
}
)
}
@@ -1170,6 +1173,7 @@ async function linkAllBins (
depGraph: DependenciesGraph,
opts: {
extraNodePaths?: string[]
preferSymlinkedExecutables?: boolean
optional: boolean
warn: (message: string) => void
}

View File

@@ -118,6 +118,7 @@ export default async function link (
const linkToBin = maybeOpts?.linkToBin ?? path.join(destModules, '.bin')
await linkBinsOfPackages(linkedPkgs.map((p) => ({ manifest: p.manifest, location: p.path })), linkToBin, {
extraNodePaths: ctx.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
})
let newPkg!: ProjectManifest

View File

@@ -23,6 +23,7 @@ interface StrictLinkOptions {
reporter: ReporterFunction
targetDependenciesField?: DependenciesField
dir: string
preferSymlinkedExecutables: boolean
hoistPattern: string[] | undefined
forceHoistPattern: boolean

View File

@@ -562,6 +562,20 @@ test('bin specified in the directories property linked to .bin folder', async ()
await project.isExecutable('.bin/pkg-with-directories-bin')
})
test('bin specified in the directories property symlinked to .bin folder when prefer-symlinked-executables is true on POSIX', async () => {
const project = prepareEmpty()
const opts = await testDefaults({ fastUnpack: false, preferSymlinkedExecutables: true })
await addDependenciesToPackage({}, ['pkg-with-directories-bin'], opts)
await project.isExecutable('.bin/pkg-with-directories-bin')
if (!isWindows()) {
const link = await fs.readlink('node_modules/.bin/pkg-with-directories-bin')
expect(link).toBeTruthy()
}
})
testOnNonWindows('building native addons', async () => {
const project = prepareEmpty()

View File

@@ -100,6 +100,7 @@ export interface HeadlessOptions {
engineStrict: boolean
extraBinPaths?: string[]
extraNodePaths?: string[]
preferSymlinkedExecutables?: boolean
hoistingLimits?: HoistingLimits
ignoreScripts: boolean
ignorePackageManifest?: boolean
@@ -304,6 +305,7 @@ export default async (opts: HeadlessOptions) => {
force: opts.force,
ignoreScripts: opts.ignoreScripts,
lockfileDir: opts.lockfileDir,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
sideEffectsCacheRead: opts.sideEffectsCacheRead,
})
stageLogger.debug({
@@ -355,6 +357,7 @@ export default async (opts: HeadlessOptions) => {
extraNodePath: opts.extraNodePaths,
lockfile: hoistLockfile,
importerIds,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
privateHoistedModulesDir: hoistedModulesDir,
privateHoistPattern: opts.hoistPattern ?? [],
publicHoistedModulesDir,
@@ -368,6 +371,7 @@ export default async (opts: HeadlessOptions) => {
await linkAllBins(graph, {
extraNodePaths: opts.extraNodePaths,
optional: opts.include.optionalDependencies,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn,
})
@@ -433,6 +437,7 @@ export default async (opts: HeadlessOptions) => {
ignoreScripts: opts.ignoreScripts,
lockfileDir,
optional: opts.include.optionalDependencies,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
rawConfig: opts.rawConfig,
rootModulesDir: virtualStoreDir,
scriptsPrependNodePath: opts.scriptsPrependNodePath,
@@ -455,7 +460,10 @@ export default async (opts: HeadlessOptions) => {
if (!opts.ignorePackageManifest) {
await Promise.all(opts.projects.map(async (project) => {
if (opts.publicHoistPattern?.length && path.relative(opts.lockfileDir, project.rootDir) === '') {
await linkBinsOfImporter(project, { extraNodePaths: opts.extraNodePaths })
await linkBinsOfImporter(project, {
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
})
} else {
const directPkgDirs = Object.values(directDependenciesByImporterId[project.id])
await linkBinsOfPackages(
@@ -471,6 +479,7 @@ export default async (opts: HeadlessOptions) => {
project.binsDir,
{
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
}
)
}
@@ -568,12 +577,13 @@ async function linkBinsOfImporter (
modulesDir: string
rootDir: string
},
{ extraNodePaths }: { extraNodePaths?: string[] } = {}
{ extraNodePaths, preferSymlinkedExecutables }: { extraNodePaths?: string[], preferSymlinkedExecutables?: boolean } = {}
) {
const warn = (message: string) => logger.info({ message, prefix: rootDir })
return linkBins(modulesDir, binsDir, {
extraNodePaths,
allowExoticManifests: true,
preferSymlinkedExecutables,
projectManifest: manifest,
warn,
})
@@ -725,6 +735,7 @@ async function linkAllBins (
opts: {
extraNodePaths?: string[]
optional: boolean
preferSymlinkedExecutables?: boolean
warn: (message: string) => void
}
) {
@@ -745,7 +756,11 @@ async function linkAllBins (
const pkgSnapshots = props<string, DependenciesGraphNode>(Object.values(childrenToLink), depGraph)
if (pkgSnapshots.includes(undefined as any)) { // eslint-disable-line
await linkBins(depNode.modules, binPath, { extraNodePaths: opts.extraNodePaths, warn: opts.warn })
await linkBins(depNode.modules, binPath, {
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn: opts.warn,
})
} else {
const pkgs = await Promise.all(
pkgSnapshots
@@ -756,13 +771,20 @@ async function linkAllBins (
}))
)
await linkBinsOfPackages(pkgs, binPath, { extraNodePaths: opts.extraNodePaths })
await linkBinsOfPackages(pkgs, binPath, {
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
})
}
// link also the bundled dependencies` bins
if (depNode.hasBundledDependencies) {
const bundledModules = path.join(depNode.dir, 'node_modules')
await linkBins(bundledModules, binPath, { extraNodePaths: opts.extraNodePaths, warn: opts.warn })
await linkBins(bundledModules, binPath, {
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn: opts.warn,
})
}
}))
)

View File

@@ -29,6 +29,7 @@ export default async function linkHoistedModules (
force: boolean
ignoreScripts: boolean
lockfileDir: string
preferSymlinkedExecutables?: boolean
sideEffectsCacheRead: boolean
}
): Promise<void> {
@@ -85,6 +86,7 @@ async function linkAllPkgsInOrder (
force: boolean
ignoreScripts: boolean
lockfileDir: string
preferSymlinkedExecutables?: boolean
sideEffectsCacheRead: boolean
warn: (message: string) => void
}
@@ -130,6 +132,7 @@ async function linkAllPkgsInOrder (
const binsDir = path.join(modulesDir, '.bin')
await linkBins(modulesDir, binsDir, {
allowExoticManifests: true,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn: opts.warn,
})
}

View File

@@ -17,6 +17,7 @@ const hoistLogger = logger('hoist')
export default async function hoistByLockfile (
opts: {
extraNodePath?: string[]
preferSymlinkedExecutables?: boolean
lockfile: Lockfile
importerIds?: string[]
privateHoistPattern: string[]
@@ -65,7 +66,10 @@ export default async function hoistByLockfile (
// the bins of the project's direct dependencies.
// This is possible because the publicly hoisted modules
// are in the same directory as the regular dependencies.
await linkAllBins(opts.privateHoistedModulesDir, { extraNodePaths: opts.extraNodePath })
await linkAllBins(opts.privateHoistedModulesDir, {
extraNodePaths: opts.extraNodePath,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
})
return hoistedDependencies
}
@@ -85,7 +89,12 @@ function createGetAliasHoistType (
}
}
async function linkAllBins (modulesDir: string, opts: { extraNodePaths?: string[] }) {
interface LinkAllBinsOptions {
extraNodePaths?: string[]
preferSymlinkedExecutables?: boolean
}
async function linkAllBins (modulesDir: string, opts: LinkAllBinsOptions) {
const bin = path.join(modulesDir, '.bin')
const warn: WarnFunction = (message, code) => {
if (code === 'BINARIES_CONFLICT') return
@@ -95,6 +104,7 @@ async function linkAllBins (modulesDir: string, opts: { extraNodePaths?: string[
await linkBins(modulesDir, bin, {
allowExoticManifests: true,
extraNodePaths: opts.extraNodePaths,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn,
})
} catch (err: any) { // eslint-disable-line

View File

@@ -44,7 +44,8 @@
"is-windows": "^1.0.2",
"normalize-path": "^3.0.0",
"p-settle": "^4.1.1",
"ramda": "^0.28.0"
"ramda": "^0.28.0",
"symlink-dir": "^5.0.1"
},
"devDependencies": {
"@pnpm/link-bins": "workspace:*",

View File

@@ -18,6 +18,7 @@ import fromPairs from 'ramda/src/fromPairs.js'
import isEmpty from 'ramda/src/isEmpty.js'
import unnest from 'ramda/src/unnest.js'
import partition from 'ramda/src/partition.js'
import symlinkDir from 'symlink-dir'
import fixBin from 'bin-links/lib/fix-bin'
const binsConflictLogger = logger('bins-conflict')
@@ -32,9 +33,8 @@ export type WarnFunction = (msg: string, code: WarningCode) => void
export default async (
modulesDir: string,
binsDir: string,
opts: {
opts: LinkBinOptions & {
allowExoticManifests?: boolean
extraNodePaths?: string[]
nodeExecPathByAlias?: Record<string, string>
projectManifest?: ProjectManifest
warn: WarnFunction
@@ -88,7 +88,7 @@ export async function linkBinsOfPackages (
location: string
}>,
binsTarget: string,
opts: { extraNodePaths?: string[] } = {}
opts: LinkBinOptions = {}
): Promise<string[]> {
if (pkgs.length === 0) return []
@@ -113,7 +113,7 @@ type CommandInfo = Command & {
async function linkBins (
allCmds: CommandInfo[],
binsDir: string,
opts: { extraNodePaths?: string[] }
opts: LinkBinOptions
): Promise<string[]> {
if (allCmds.length === 0) return [] as string[]
@@ -193,9 +193,19 @@ async function getPackageBinsFromManifest (manifest: DependencyManifest, pkgDir:
}))
}
async function linkBin (cmd: CommandInfo, binsDir: string, opts?: { extraNodePaths?: string[] }) {
export interface LinkBinOptions {
extraNodePaths?: string[]
preferSymlinkedExecutables?: boolean
}
async function linkBin (cmd: CommandInfo, binsDir: string, opts?: LinkBinOptions) {
const externalBinPath = path.join(binsDir, cmd.name)
if (opts?.preferSymlinkedExecutables && !IS_WINDOWS && cmd.nodeExecPath == null) {
await symlinkDir(cmd.path, externalBinPath)
return
}
try {
await cmdShim(cmd.path, externalBinPath, {
createPwshFile: cmd.makePowerShellShim,

2
pnpm-lock.yaml generated
View File

@@ -1321,6 +1321,7 @@ importers:
p-settle: ^4.1.1
path-exists: ^4.0.0
ramda: ^0.28.0
symlink-dir: ^5.0.1
tempy: ^1.0.1
dependencies:
'@pnpm/error': link:../error
@@ -1337,6 +1338,7 @@ importers:
normalize-path: 3.0.0
p-settle: 4.1.1
ramda: 0.28.0
symlink-dir: 5.0.1
devDependencies:
'@pnpm/link-bins': 'link:'
'@pnpm/logger': 4.0.0