feat!: the link command should add overrides (#8653)

This commit is contained in:
Zoltan Kochan
2024-10-24 16:59:55 +02:00
committed by GitHub
parent 496fbecd6c
commit 477e0c1f74
23 changed files with 196 additions and 1338 deletions

View File

@@ -0,0 +1,13 @@
---
"@pnpm/plugin-commands-installation": major
"@pnpm/core": major
"@pnpm/config": major
"pnpm": major
---
The `pnpm link` command adds overrides to the root `package.json`. In a workspace the override is added to the root of the workspace, so it links the dependency to all projects in a workspace.
To link a package globally, just run `pnpm link` from the package's directory. Previously, the command `pnpm link -g` was required to link a package globally.
Related PR: [#8653](https://github.com/pnpm/pnpm/pull/8653).

View File

@@ -197,6 +197,7 @@ export interface Config {
extendNodePath?: boolean
gitBranchLockfile?: boolean
globalDir?: string
globalPkgDir: string
lockfile?: boolean
dedupeInjectedDeps?: boolean
nodeOptions?: string

View File

@@ -260,16 +260,16 @@ export async function getConfig (opts: {
return undefined
})()
pnpmConfig.pnpmHomeDir = getDataDir(process)
let globalDirRoot
if (pnpmConfig.globalDir) {
globalDirRoot = pnpmConfig.globalDir
} else {
globalDirRoot = path.join(pnpmConfig.pnpmHomeDir, 'global')
}
pnpmConfig.globalPkgDir = path.join(globalDirRoot, LAYOUT_VERSION.toString())
if (cliOptions['global']) {
let globalDirRoot
if (pnpmConfig.globalDir) {
globalDirRoot = pnpmConfig.globalDir
} else {
globalDirRoot = path.join(pnpmConfig.pnpmHomeDir, 'global')
}
pnpmConfig.dir = path.join(globalDirRoot, LAYOUT_VERSION.toString())
pnpmConfig.dir = pnpmConfig.globalPkgDir
pnpmConfig.bin = npmConfig.get('global-bin-dir') ?? env.PNPM_HOME
if (pnpmConfig.bin) {
fs.mkdirSync(pnpmConfig.bin, { recursive: true })

View File

@@ -1,4 +1,3 @@
export * from './install'
export { PeerDependencyIssuesError } from './install/reportPeerDependencyIssues'
export * from './link'
export * from './getPeerDependencyIssues'

View File

@@ -45,9 +45,7 @@ import { getPreferredVersionsFromLockfileAndManifests } from '@pnpm/lockfile.pre
import { logger, globalInfo, streamParser } from '@pnpm/logger'
import { getAllDependenciesFromManifest, getAllUniqueSpecs } from '@pnpm/manifest-utils'
import { writeModulesManifest } from '@pnpm/modules-yaml'
import { readModulesDir } from '@pnpm/read-modules-dir'
import { safeReadProjectManifestOnly } from '@pnpm/read-project-manifest'
import { removeBin } from '@pnpm/remove-bins'
import {
getWantedDependencies,
type DependenciesGraph,
@@ -70,10 +68,7 @@ import {
type ReadPackageHook,
type ProjectRootDir,
} from '@pnpm/types'
import rimraf from '@zkochan/rimraf'
import isInnerLink from 'is-inner-link'
import isSubdir from 'is-subdir'
import pFilter from 'p-filter'
import pLimit from 'p-limit'
import mapValues from 'ramda/src/map'
import clone from 'ramda/src/clone'
@@ -133,20 +128,12 @@ export interface UninstallSomeDepsMutation {
targetDependenciesField?: DependenciesField
}
export interface UnlinkDepsMutation {
mutation: 'unlink'
}
export interface UnlinkSomeDepsMutation {
mutation: 'unlinkSome'
dependencyNames: string[]
}
export type DependenciesMutation = InstallDepsMutation | InstallSomeDepsMutation | UninstallSomeDepsMutation | UnlinkDepsMutation | UnlinkSomeDepsMutation
export type DependenciesMutation = InstallDepsMutation | InstallSomeDepsMutation | UninstallSomeDepsMutation
type Opts = Omit<InstallOptions, 'allProjects'> & {
preferredVersions?: PreferredVersions
pruneDirectDependencies?: boolean
binsDir?: string
} & InstallMutationOptions
export async function install (
@@ -171,6 +158,7 @@ export async function install (
buildIndex: 0,
manifest,
rootDir,
binsDir: opts.binsDir,
}],
}
)
@@ -568,70 +556,6 @@ Note that in CI environments, this setting is enabled by default.`,
})
break
}
case 'unlink': {
const packageDirs = await readModulesDir(projectOpts.modulesDir)
const externalPackages = await pFilter(
packageDirs!,
async (packageDir: string) => isExternalLink(ctx.storeDir, projectOpts.modulesDir, packageDir)
)
const allDeps = getAllDependenciesFromManifest(projectOpts.manifest)
const packagesToInstall: string[] = []
for (const pkgName of externalPackages) {
await rimraf(path.join(projectOpts.modulesDir, pkgName))
if (allDeps[pkgName]) {
packagesToInstall.push(pkgName)
}
}
if (packagesToInstall.length === 0) {
return {
updatedProjects: projects.map((mutatedProject) => ctx.projects[mutatedProject.rootDir]),
}
}
// TODO: install only those that were unlinked
// but don't update their version specs in package.json
await installCase({ ...projectOpts, mutation: 'install' })
break
}
case 'unlinkSome': {
if (projectOpts.manifest?.name && opts.globalBin) {
await removeBin(path.join(opts.globalBin, projectOpts.manifest?.name))
}
const packagesToInstall: string[] = []
const allDeps = getAllDependenciesFromManifest(projectOpts.manifest)
for (const depName of project.dependencyNames) {
try {
if (!await isExternalLink(ctx.storeDir, projectOpts.modulesDir, depName)) {
logger.warn({
message: `${depName} is not an external link`,
prefix: project.rootDir,
})
continue
}
} catch (err: any) { // eslint-disable-line
if (err['code'] !== 'ENOENT') throw err
}
await rimraf(path.join(projectOpts.modulesDir, depName))
if (allDeps[depName]) {
packagesToInstall.push(depName)
}
}
if (packagesToInstall.length === 0) {
return {
updatedProjects: projects.map((mutatedProject) => ctx.projects[mutatedProject.rootDir]),
}
}
// TODO: install only those that were unlinked
// but don't update their version specs in package.json
await installSome({
...projectOpts,
dependencySelectors: packagesToInstall,
mutation: 'installSome',
updatePackageManifest: false,
})
break
}
}
}
/* eslint-enable no-await-in-loop */
@@ -747,12 +671,6 @@ function cacheExpired (prunedAt: string, maxAgeInMinutes: number): boolean {
return ((Date.now() - new Date(prunedAt).valueOf()) / (1000 * 60)) > maxAgeInMinutes
}
async function isExternalLink (storeDir: string, modules: string, pkgName: string): Promise<boolean> {
const link = await isInnerLink(modules, pkgName)
return !link.isInner
}
function pkgHasDependencies (manifest: ProjectManifest): boolean {
return Boolean(
(Object.keys(manifest.dependencies ?? {}).length > 0) ||

View File

@@ -1,191 +0,0 @@
import path from 'path'
import {
summaryLogger,
} from '@pnpm/core-loggers'
import { PnpmError } from '@pnpm/error'
import { getContextForSingleImporter } from '@pnpm/get-context'
import { linkBinsOfPackages } from '@pnpm/link-bins'
import {
getLockfileImporterId,
type ProjectSnapshot,
writeCurrentLockfile,
writeLockfiles,
} from '@pnpm/lockfile.fs'
import { logger, streamParser } from '@pnpm/logger'
import {
getPref,
getSpecFromPackageManifest,
getDependencyTypeFromManifest,
guessDependencyType,
type PackageSpecObject,
updateProjectManifestObject,
} from '@pnpm/manifest-utils'
import { pruneSharedLockfile } from '@pnpm/lockfile.pruner'
import { readProjectManifest } from '@pnpm/read-project-manifest'
import { symlinkDirectRootDependency } from '@pnpm/symlink-dependency'
import {
type DependenciesField,
DEPENDENCIES_FIELDS,
type DependencyManifest,
type ProjectManifest,
} from '@pnpm/types'
import normalize from 'normalize-path'
import {
extendOptions,
type LinkOptions,
} from './options'
type LinkFunctionOptions = LinkOptions & {
linkToBin?: string
dir: string
}
export type { LinkFunctionOptions }
export async function link (
linkFromPkgs: Array<{ alias: string, path: string } | string>,
destModules: string,
maybeOpts: LinkFunctionOptions
): Promise<ProjectManifest> {
const reporter = maybeOpts?.reporter
if ((reporter != null) && typeof reporter === 'function') {
streamParser.on('data', reporter)
}
const opts = await extendOptions(maybeOpts)
const ctx = await getContextForSingleImporter(opts.manifest, {
...opts,
extraBinPaths: [], // ctx.extraBinPaths is not needed, so this is fine
}, true)
const importerId = getLockfileImporterId(ctx.lockfileDir, opts.dir)
const specsToUpsert = [] as PackageSpecObject[]
const linkedPkgs = await Promise.all(
linkFromPkgs.map(async (linkFrom) => {
let linkFromPath: string
let linkFromAlias: string | undefined
if (typeof linkFrom === 'string') {
linkFromPath = linkFrom
} else {
linkFromPath = linkFrom.path
linkFromAlias = linkFrom.alias
}
const { manifest } = await readProjectManifest(linkFromPath) as { manifest: DependencyManifest }
if (typeof linkFrom === 'string' && manifest.name === undefined) {
throw new PnpmError('INVALID_PACKAGE_NAME', `Package in ${linkFromPath} must have a name field to be linked`)
}
const targetDependencyType = getDependencyTypeFromManifest(opts.manifest, manifest.name) ?? opts.targetDependenciesField
specsToUpsert.push({
alias: manifest.name,
pref: getPref(manifest.name, manifest.name, manifest.version, {
pinnedVersion: opts.pinnedVersion,
}),
saveType: (targetDependencyType ?? (ctx.manifest && guessDependencyType(manifest.name, ctx.manifest))) as DependenciesField,
})
const packagePath = normalize(path.relative(opts.dir, linkFromPath))
const addLinkOpts = {
linkedPkgName: linkFromAlias ?? manifest.name,
manifest: ctx.manifest,
packagePath,
}
addLinkToLockfile(ctx.currentLockfile.importers[importerId], addLinkOpts)
addLinkToLockfile(ctx.wantedLockfile.importers[importerId], addLinkOpts)
return {
alias: linkFromAlias ?? manifest.name,
manifest,
path: linkFromPath,
}
})
)
const updatedCurrentLockfile = pruneSharedLockfile(ctx.currentLockfile)
const warn = (message: string) => {
logger.warn({ message, prefix: opts.dir })
}
const updatedWantedLockfile = pruneSharedLockfile(ctx.wantedLockfile, { warn })
// Linking should happen after removing orphans
// Otherwise would've been removed
await Promise.all(linkedPkgs.map(async ({ alias, manifest, path }) => {
// TODO: cover with test that linking reports with correct dependency types
const stu = specsToUpsert.find((s) => s.alias === manifest.name)
const targetDependencyType = getDependencyTypeFromManifest(opts.manifest, manifest.name) ?? opts.targetDependenciesField
await symlinkDirectRootDependency(path, destModules, alias, {
fromDependenciesField: stu?.saveType ?? (targetDependencyType as DependenciesField),
linkedPackage: manifest,
prefix: opts.dir,
})
}))
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
if (opts.targetDependenciesField) {
newPkg = await updateProjectManifestObject(opts.dir, opts.manifest, specsToUpsert)
for (const { alias } of specsToUpsert) {
updatedWantedLockfile.importers[importerId].specifiers[alias] = getSpecFromPackageManifest(newPkg, alias)
}
} else {
newPkg = opts.manifest
}
const lockfileOpts = { useGitBranchLockfile: opts.useGitBranchLockfile, mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles }
if (opts.useLockfile) {
await writeLockfiles({
currentLockfile: updatedCurrentLockfile,
currentLockfileDir: ctx.virtualStoreDir,
wantedLockfile: updatedWantedLockfile,
wantedLockfileDir: ctx.lockfileDir,
...lockfileOpts,
})
} else {
await writeCurrentLockfile(ctx.virtualStoreDir, updatedCurrentLockfile)
}
summaryLogger.debug({ prefix: opts.dir })
if ((reporter != null) && typeof reporter === 'function') {
streamParser.removeListener('data', reporter)
}
return newPkg
}
function addLinkToLockfile (
projectSnapshot: ProjectSnapshot,
opts: {
linkedPkgName: string
packagePath: string
manifest?: ProjectManifest
}
) {
const id = `link:${opts.packagePath}`
let addedTo: DependenciesField | undefined
for (const depType of DEPENDENCIES_FIELDS) {
if (!addedTo && opts.manifest?.[depType]?.[opts.linkedPkgName]) {
addedTo = depType
projectSnapshot[depType] = projectSnapshot[depType] ?? {}
projectSnapshot[depType]![opts.linkedPkgName] = id
} else if (projectSnapshot[depType] != null) {
delete projectSnapshot[depType]![opts.linkedPkgName]
}
}
// package.json might not be available when linking to global
if (opts.manifest == null) return
const availableSpec = getSpecFromPackageManifest(opts.manifest, opts.linkedPkgName)
if (availableSpec) {
projectSnapshot.specifiers[opts.linkedPkgName] = availableSpec
} else {
delete projectSnapshot.specifiers[opts.linkedPkgName]
}
}

View File

@@ -1,74 +0,0 @@
import path from 'path'
import { normalizeRegistries, DEFAULT_REGISTRIES } from '@pnpm/normalize-registries'
import { type StoreController } from '@pnpm/store-controller-types'
import {
type DependenciesField,
type ProjectManifest,
type Registries,
} from '@pnpm/types'
import { type ReporterFunction } from '../types'
interface StrictLinkOptions {
autoInstallPeers: boolean
binsDir: string
excludeLinksFromLockfile: boolean
force: boolean
useLockfile: boolean
lockfileDir: string
nodeLinker: 'isolated' | 'hoisted' | 'pnp'
pinnedVersion: 'major' | 'minor' | 'patch'
storeController: StoreController
manifest: ProjectManifest
registries: Registries
storeDir: string
reporter: ReporterFunction
targetDependenciesField?: DependenciesField
dir: string
preferSymlinkedExecutables: boolean
hoistPattern: string[] | undefined
forceHoistPattern: boolean
publicHoistPattern: string[] | undefined
forcePublicHoistPattern: boolean
useGitBranchLockfile: boolean
mergeGitBranchLockfiles: boolean
virtualStoreDirMaxLength: number
peersSuffixMaxLength: number
}
export type LinkOptions =
& Partial<StrictLinkOptions>
& Pick<StrictLinkOptions, 'storeController' | 'manifest'>
export async function extendOptions (opts: LinkOptions): Promise<StrictLinkOptions> {
if (opts) {
for (const key in opts) {
if (opts[key as keyof LinkOptions] === undefined) {
delete opts[key as keyof LinkOptions]
}
}
}
const defaultOpts = await defaults(opts)
const extendedOpts = { ...defaultOpts, ...opts, storeDir: defaultOpts.storeDir }
extendedOpts.registries = normalizeRegistries(extendedOpts.registries)
return extendedOpts
}
async function defaults (opts: LinkOptions): Promise<StrictLinkOptions> {
const dir = opts.dir ?? process.cwd()
return {
binsDir: path.join(dir, 'node_modules', '.bin'),
dir,
force: false,
hoistPattern: undefined,
lockfileDir: opts.lockfileDir ?? dir,
nodeLinker: 'isolated',
registries: DEFAULT_REGISTRIES,
storeController: opts.storeController,
storeDir: opts.storeDir,
useLockfile: true,
virtualStoreDirMaxLength: 120,
} as StrictLinkOptions
}

View File

@@ -3,7 +3,6 @@ import { testDefaults } from './utils'
test('API', () => {
expect(typeof pnpm.install).toBe('function')
expect(typeof pnpm.link).toBe('function')
})
// TODO: some sort of this validation might need to exist

View File

@@ -1,52 +1,13 @@
import fs from 'fs'
import path from 'path'
import {
addDependenciesToPackage,
install,
link,
} from '@pnpm/core'
import { addDependenciesToPackage, install } from '@pnpm/core'
import { fixtures } from '@pnpm/test-fixtures'
import { prepareEmpty } from '@pnpm/prepare'
import { addDistTag } from '@pnpm/registry-mock'
import { type RootLog } from '@pnpm/core-loggers'
import sinon from 'sinon'
import writeJsonFile from 'write-json-file'
import symlink from 'symlink-dir'
import symlinkDir from 'symlink-dir'
import { testDefaults } from './utils'
const f = fixtures(__dirname)
test('relative link', async () => {
const project = prepareEmpty()
const linkedPkgName = 'hello-world-js-bin'
const linkedPkgPath = path.resolve('..', linkedPkgName)
f.copy(linkedPkgName, linkedPkgPath)
await link([`../${linkedPkgName}`], path.join(process.cwd(), 'node_modules'), testDefaults({
dir: process.cwd(),
manifest: {
dependencies: {
'@pnpm.e2e/hello-world-js-bin': '*',
},
},
}))
project.isExecutable('.bin/hello-world-js-bin')
const wantedLockfile = project.readLockfile()
expect(wantedLockfile.importers['.'].dependencies?.['@pnpm.e2e/hello-world-js-bin']).toStrictEqual({
version: 'link:../hello-world-js-bin',
specifier: '*',
})
const currentLockfile = project.readCurrentLockfile()
expect(currentLockfile.importers['.'].dependencies?.['@pnpm.e2e/hello-world-js-bin']).toStrictEqual({
version: 'link:../hello-world-js-bin',
specifier: '*',
})
})
test('relative link is linked by the name of the alias', async () => {
const linkedPkgName = 'hello-world-js-bin'
@@ -80,34 +41,10 @@ test('relative link is not rewritten by argumentless install', async () => {
const linkedPkgName = 'hello-world-js-bin'
const linkedPkgPath = path.resolve('..', linkedPkgName)
const reporter = sinon.spy()
const opts = testDefaults()
f.copy(linkedPkgName, linkedPkgPath)
const manifest = await link(
[linkedPkgPath],
path.join(process.cwd(), 'node_modules'),
{
...opts,
dir: process.cwd(),
manifest: {},
reporter,
})
symlinkDir.sync(linkedPkgPath, path.resolve('node_modules/@pnpm.e2e/hello-world-js-bin'))
expect(reporter.calledWithMatch({
added: {
dependencyType: undefined,
linkedFrom: linkedPkgPath,
name: '@pnpm.e2e/hello-world-js-bin',
realName: '@pnpm.e2e/hello-world-js-bin',
version: '1.0.0',
},
level: 'debug',
name: 'pnpm:root',
prefix: process.cwd(),
} as RootLog)).toBeTruthy()
await install(manifest, opts)
await install({}, testDefaults())
expect(project.requireModule('@pnpm.e2e/hello-world-js-bin/package.json').isLocal).toBeTruthy()
})
@@ -119,35 +56,12 @@ test('relative link is rewritten by named installation to regular dependency', a
const linkedPkgName = 'hello-world-js-bin'
const linkedPkgPath = path.resolve('..', linkedPkgName)
const reporter = sinon.spy()
const opts = testDefaults({ fastUnpack: false })
f.copy(linkedPkgName, linkedPkgPath)
let manifest = await link(
[linkedPkgPath],
path.join(process.cwd(), 'node_modules'),
{
...opts,
dir: process.cwd(),
manifest: {},
reporter,
}
)
symlinkDir.sync(linkedPkgPath, path.resolve('node_modules/@pnpm.e2e/hello-world-js-bin'))
expect(reporter.calledWithMatch({
added: {
dependencyType: undefined,
linkedFrom: linkedPkgPath,
name: '@pnpm.e2e/hello-world-js-bin',
realName: '@pnpm.e2e/hello-world-js-bin',
version: '1.0.0',
},
level: 'debug',
name: 'pnpm:root',
prefix: process.cwd(),
} as RootLog)).toBeTruthy()
manifest = await addDependenciesToPackage(manifest, ['@pnpm.e2e/hello-world-js-bin'], opts)
const manifest = await addDependenciesToPackage({}, ['@pnpm.e2e/hello-world-js-bin'], opts)
expect(manifest.dependencies).toStrictEqual({ '@pnpm.e2e/hello-world-js-bin': '^1.0.0' })
@@ -160,91 +74,6 @@ test('relative link is rewritten by named installation to regular dependency', a
expect(currentLockfile.importers['.'].dependencies?.['@pnpm.e2e/hello-world-js-bin'].version).toBe('1.0.0')
})
test('relative link uses realpath when contained in a symlinked dir', async () => {
prepareEmpty()
// `process.cwd()` is now `.tmp/X/project`.
f.copy('symlink-workspace', path.resolve('../symlink-workspace'))
const app1RelPath = '../symlink-workspace/app1'
const app2RelPath = '../symlink-workspace/app2'
const app1 = path.resolve(app1RelPath)
const app2 = path.resolve(app2RelPath)
const dest = path.join(app2, 'packages/public')
const src = path.resolve(app1, 'packages/public')
console.log(`${dest}->${src}`)
// We must manually create the symlink so it works in Windows too.
await symlink(src, dest)
process.chdir(path.join(app2, '/packages/public/foo'))
// `process.cwd()` is now `.tmp/X/symlink-workspace/app2/packages/public/foo`.
const linkFrom = path.join(app1, '/packages/public/bar')
const linkTo = path.join(app2, '/packages/public/foo', 'node_modules')
await link([linkFrom], linkTo, testDefaults({ manifest: {}, dir: process.cwd() }))
const linkToRelLink = fs.readlinkSync(path.join(linkTo, 'bar'))
if (process.platform === 'win32') {
expect(path.relative(linkToRelLink, path.join(src, 'bar'))).toBe('') // link points to real location
} else {
expect(linkToRelLink).toBe('../../bar')
// If we don't use real paths we get a link like this.
expect(linkToRelLink).not.toBe('../../../../../app1/packages/public/bar')
}
})
test('throws error is package name is not defined', async () => {
prepareEmpty()
writeJsonFile.sync('../is-positive/package.json', { version: '1.0.0' })
const manifest = await addDependenciesToPackage({}, ['is-positive@1.0.0'], testDefaults())
try {
await link(['../is-positive'], path.resolve('node_modules'), testDefaults({ manifest, dir: process.cwd() }))
throw new Error('link package should fail')
} catch (err: any) { // eslint-disable-line
expect(err.message).toBe('Package in ../is-positive must have a name field to be linked')
expect(err.code).toBe('ERR_PNPM_INVALID_PACKAGE_NAME')
}
})
test('link should not change the type of the dependency', async () => {
const project = prepareEmpty()
const linkedPkgName = 'hello-world-js-bin'
const linkedPkgPath = path.resolve('..', linkedPkgName)
f.copy(linkedPkgName, linkedPkgPath)
await link([`../${linkedPkgName}`], path.join(process.cwd(), 'node_modules'), testDefaults({
dir: process.cwd(),
manifest: {
devDependencies: {
'@pnpm.e2e/hello-world-js-bin': '*',
},
},
}))
project.isExecutable('.bin/hello-world-js-bin')
const wantedLockfile = project.readLockfile()
expect(wantedLockfile.importers['.'].devDependencies).toStrictEqual({
'@pnpm.e2e/hello-world-js-bin': {
version: 'link:../hello-world-js-bin',
specifier: '*',
},
})
})
// test.skip('relative link when an external lockfile is used', async () => {
// const projects = prepare(t, [
// {

View File

@@ -5,11 +5,11 @@ import { fixtures } from '@pnpm/test-fixtures'
import {
addDependenciesToPackage,
install,
link,
mutateModulesInSingleProject,
} from '@pnpm/core'
import { type ProjectRootDir } from '@pnpm/types'
import sinon from 'sinon'
import symlinkDir from 'symlink-dir'
import { testDefaults } from './utils'
const f = fixtures(__dirname)
@@ -23,7 +23,7 @@ test('prune removes extraneous packages', async () => {
manifest = await addDependenciesToPackage(manifest, ['applyq@0.2.1'], { ...opts, targetDependenciesField: 'devDependencies' })
manifest = await addDependenciesToPackage(manifest, ['fnumber@0.1.0'], { ...opts, targetDependenciesField: 'optionalDependencies' })
manifest = await addDependenciesToPackage(manifest, ['is-positive@2.0.0', '@zkochan/logger@0.1.0'], opts)
manifest = await link([linkedPkg], path.resolve('node_modules'), { ...opts, manifest, dir: process.cwd() })
symlinkDir.sync(linkedPkg, path.resolve('node_modules/@pnpm.e2e/hello-world-js-bin'))
project.has('@pnpm.e2e/hello-world-js-bin') // external link added

View File

@@ -12,9 +12,9 @@ import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { fixtures } from '@pnpm/test-fixtures'
import { type ProjectRootDir, type PackageManifest } from '@pnpm/types'
import { sync as readYamlFile } from 'read-yaml-file'
import symlinkDir from 'symlink-dir'
import {
addDependenciesToPackage,
link,
mutateModules,
mutateModulesInSingleProject,
} from '@pnpm/core'
@@ -195,15 +195,16 @@ test('relative link is uninstalled', async () => {
const linkedPkgPath = path.resolve('..', linkedPkgName)
f.copy(linkedPkgName, linkedPkgPath)
const manifest = await link([`../${linkedPkgName}`], path.join(process.cwd(), 'node_modules'), opts as (typeof opts & { dir: string, manifest: PackageManifest }))
symlinkDir.sync(linkedPkgPath, path.resolve('node_modules/@pnpm.e2e/hello-world-js-bin'))
project.has('@pnpm.e2e/hello-world-js-bin')
await mutateModulesInSingleProject({
dependencyNames: [linkedPkgName],
manifest,
dependencyNames: ['@pnpm.e2e/hello-world-js-bin'],
manifest: {},
mutation: 'uninstallSome',
rootDir: process.cwd() as ProjectRootDir,
}, opts)
project.hasNot(linkedPkgName)
project.hasNot('@pnpm.e2e/hello-world-js-bin')
})
test('pendingBuilds gets updated after uninstall', async () => {

View File

@@ -1,296 +0,0 @@
import fs from 'fs'
import path from 'path'
import {
addDependenciesToPackage,
install,
link,
mutateModulesInSingleProject,
} from '@pnpm/core'
import { prepareEmpty } from '@pnpm/prepare'
import { WANTED_LOCKFILE } from '@pnpm/constants'
import { addDistTag } from '@pnpm/registry-mock'
import { type ProjectRootDir } from '@pnpm/types'
import sinon from 'sinon'
import writeJsonFile from 'write-json-file'
import isInnerLink from 'is-inner-link'
import { testDefaults } from './utils'
test('unlink 1 package that exists in package.json', async () => {
const project = prepareEmpty()
process.chdir('..')
await Promise.all([
writeJsonFile('is-subdir/package.json', {
dependencies: {
'is-windows': '^1.0.0',
},
name: 'is-subdir',
version: '1.0.0',
}),
writeJsonFile('is-positive/package.json', {
name: 'is-positive',
version: '1.0.0',
}),
])
// TODO: unset useLockfileV6
const opts = testDefaults({ fastUnpack: false, store: path.resolve('.store'), useLockfileV6: false })
let manifest = await link(
['is-subdir', 'is-positive'],
path.join('project', 'node_modules'),
{
...opts,
dir: path.resolve('project'),
manifest: {
dependencies: {
'is-positive': '^1.0.0',
'is-subdir': '^1.0.0',
},
},
}
)
process.chdir('project')
manifest = await install(manifest, opts)
await mutateModulesInSingleProject({
dependencyNames: ['is-subdir'],
manifest,
mutation: 'unlinkSome',
rootDir: process.cwd() as ProjectRootDir,
}, opts)
expect(typeof project.requireModule('is-subdir')).toBe('function')
expect((await isInnerLink('node_modules', 'is-positive')).isInner).toBeFalsy()
})
test("don't update package when unlinking", async () => {
const project = prepareEmpty()
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.0.0', distTag: 'latest' })
const opts = testDefaults({ dir: process.cwd() })
let manifest = await addDependenciesToPackage({}, ['@pnpm.e2e/foo'], opts)
process.chdir('..')
writeJsonFile.sync('foo/package.json', {
name: '@pnpm.e2e/foo',
version: '100.0.0',
})
manifest = await link(['foo'], path.join('project', 'node_modules'), { ...opts, manifest })
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
process.chdir('project')
await mutateModulesInSingleProject({
dependencyNames: ['@pnpm.e2e/foo'],
manifest,
mutation: 'unlinkSome',
rootDir: process.cwd() as ProjectRootDir,
}, opts)
expect(project.requireModule('@pnpm.e2e/foo/package.json').version).toBe('100.0.0')
})
test(`don't update package when unlinking. Initial link is done on a package w/o ${WANTED_LOCKFILE}`, async () => {
const project = prepareEmpty()
const opts = testDefaults({ dir: process.cwd(), resolutionMode: 'lowest-direct' })
process.chdir('..')
writeJsonFile.sync('foo/package.json', {
name: '@pnpm.e2e/foo',
version: '100.0.0',
})
const manifest = await link(['foo'], path.join('project', 'node_modules'), {
...opts,
manifest: {
dependencies: {
'@pnpm.e2e/foo': '^100.0.0',
},
},
})
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
process.chdir('project')
const unlinkResult = await mutateModulesInSingleProject({
dependencyNames: ['@pnpm.e2e/foo'],
manifest,
mutation: 'unlinkSome',
rootDir: process.cwd() as ProjectRootDir,
}, opts)
expect(project.requireModule('@pnpm.e2e/foo/package.json').version).toBe('100.0.0')
expect(unlinkResult.manifest.dependencies).toStrictEqual({ '@pnpm.e2e/foo': '^100.0.0' })
})
test('unlink 2 packages. One of them exists in package.json', async () => {
const project = prepareEmpty()
const opts = testDefaults({ fastUnpack: false, dir: process.cwd() })
process.chdir('..')
await Promise.all([
writeJsonFile('is-subdir/package.json', {
dependencies: {
'is-windows': '^1.0.0',
},
name: 'is-subdir',
version: '1.0.0',
}),
writeJsonFile('is-positive/package.json', {
name: 'is-positive',
version: '1.0.0',
}),
])
const manifest = await link(['is-subdir', 'is-positive'], path.join('project', 'node_modules'), {
...opts,
manifest: {
dependencies: {
'is-subdir': '^1.0.0',
},
},
})
process.chdir('project')
await mutateModulesInSingleProject({
dependencyNames: ['is-subdir', 'is-positive'],
manifest,
mutation: 'unlinkSome',
rootDir: process.cwd() as ProjectRootDir,
}, opts)
expect(typeof project.requireModule('is-subdir')).toBe('function')
expect(fs.existsSync(path.join('node_modules', 'is-positive'))).toBeFalsy()
})
test('unlink all packages', async () => {
const project = prepareEmpty()
const opts = testDefaults({ fastUnpack: false, dir: process.cwd() })
process.chdir('..')
await Promise.all([
writeJsonFile('is-subdir/package.json', {
dependencies: {
'is-windows': '^1.0.0',
},
name: 'is-subdir',
version: '1.0.0',
}),
writeJsonFile('logger/package.json', {
name: '@zkochan/logger',
version: '0.1.0',
}),
])
const manifest = await link(['is-subdir', 'logger'], path.join('project', 'node_modules'), {
...opts,
manifest: {
dependencies: {
'@zkochan/logger': '^0.1.0',
'is-subdir': '^1.0.0',
},
},
})
await mutateModulesInSingleProject({
manifest,
mutation: 'unlink',
rootDir: path.resolve('project') as ProjectRootDir,
}, opts)
expect(typeof project.requireModule('is-subdir')).toBe('function')
expect(typeof project.requireModule('@zkochan/logger')).toBe('object')
})
test("don't warn about scoped packages when running unlink w/o params", async () => {
prepareEmpty()
const manifest = await addDependenciesToPackage({}, ['@zkochan/logger'], testDefaults())
const reporter = sinon.spy()
await mutateModulesInSingleProject({
manifest,
mutation: 'unlink',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({ reporter }))
expect(reporter.calledWithMatch({
level: 'warn',
message: '@zkochan/logger is not an external link',
})).toBeFalsy()
})
test("don't unlink package that is not a link", async () => {
prepareEmpty()
const reporter = sinon.spy()
const manifest = await addDependenciesToPackage({}, ['is-positive'], testDefaults())
await mutateModulesInSingleProject({
dependencyNames: ['is-positive'],
manifest,
mutation: 'unlinkSome',
rootDir: process.cwd() as ProjectRootDir,
}, testDefaults({ reporter }))
expect(reporter.calledWithMatch({
level: 'warn',
message: 'is-positive is not an external link',
})).toBeTruthy()
})
test('unlink would remove global bin', async () => {
prepareEmpty()
process.chdir('..')
fs.mkdirSync('bin')
fs.mkdirSync('is-subdir')
fs.writeFileSync('is-subdir/index.js', ' ')
await Promise.all([
writeJsonFile('is-subdir/package.json', {
bin: 'index.js',
dependencies: {
'is-windows': '^1.0.0',
},
name: 'is-subdir',
version: '1.0.0',
}),
])
const opts = testDefaults({
fastUnpack: false,
globalBin: path.resolve('bin'),
linkToBin: path.resolve('bin'),
store: path.resolve('.store'),
})
const manifest = await link(
['is-subdir'],
path.join('project', 'node_modules'),
{
...opts,
dir: path.resolve('project'),
manifest: {
dependencies: {
'is-subdir': '^1.0.0',
},
name: 'is-subdir',
},
}
)
expect(fs.existsSync(path.resolve('bin/is-subdir'))).toBeTruthy()
await mutateModulesInSingleProject({
dependencyNames: ['is-subdir'],
manifest,
mutation: 'unlinkSome',
rootDir: process.cwd() as ProjectRootDir,
}, opts)
expect(fs.existsSync(path.resolve('bin/is-subdir'))).toBeFalsy()
})

View File

@@ -81,6 +81,7 @@
"@pnpm/plugin-commands-rebuild": "workspace:*",
"@pnpm/pnpmfile": "workspace:*",
"@pnpm/read-project-manifest": "workspace:*",
"@pnpm/write-project-manifest": "workspace:*",
"@pnpm/resolver-base": "workspace:*",
"@pnpm/semver-diff": "catalog:",
"@pnpm/sort-packages": "workspace:*",
@@ -101,7 +102,6 @@
"mem": "catalog:",
"p-filter": "catalog:",
"p-limit": "catalog:",
"path-absolute": "catalog:",
"ramda": "catalog:",
"render-help": "catalog:",
"version-selector-type": "catalog:",

View File

@@ -308,6 +308,7 @@ export type InstallCommandOptions = Pick<Config,
original: string[]
}
fixLockfile?: boolean
frozenLockfileIfExists?: boolean
useBetaCli?: boolean
pruneDirectDependencies?: boolean
pruneStore?: boolean
@@ -330,9 +331,11 @@ export async function handler (opts: InstallCommandOptions): Promise<void> {
const fetchFullMetadata: true | undefined = opts.rootProjectManifest?.pnpm?.supportedArchitectures?.libc && true
const installDepsOptions: InstallDepsOptions = {
...opts,
frozenLockfileIfExists: isCI && !opts.lockfileOnly &&
frozenLockfileIfExists: opts.frozenLockfileIfExists ?? (
isCI && !opts.lockfileOnly &&
typeof opts.rawLocalConfig['frozen-lockfile'] === 'undefined' &&
typeof opts.rawLocalConfig['prefer-frozen-lockfile'] === 'undefined',
typeof opts.rawLocalConfig['prefer-frozen-lockfile'] === 'undefined'
),
include,
includeDirect: include,
prepareExecutionEnv: prepareExecutionEnv.bind(null, opts),

View File

@@ -1,53 +1,44 @@
import path from 'path'
import {
docsUrl,
getConfig,
readProjectManifest,
readProjectManifestOnly,
tryReadProjectManifest,
type ReadProjectManifestOpts,
} from '@pnpm/cli-utils'
import { UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { type Config, getOptionsFromRootManifest, types as allTypes } from '@pnpm/config'
import { type Config, types as allTypes } from '@pnpm/config'
import { DEPENDENCIES_FIELDS, type ProjectManifest, type Project } from '@pnpm/types'
import { PnpmError } from '@pnpm/error'
import { findWorkspaceDir } from '@pnpm/find-workspace-dir'
import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context'
import { findWorkspacePackages } from '@pnpm/workspace.find-packages'
import { type StoreController } from '@pnpm/package-store'
import { createOrConnectStoreControllerCached, type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
import { writeProjectManifest } from '@pnpm/write-project-manifest'
import {
addDependenciesToPackage,
install,
type InstallOptions,
link,
type LinkFunctionOptions,
type WorkspacePackages,
} from '@pnpm/core'
import { logger } from '@pnpm/logger'
import { type Project } from '@pnpm/types'
import pLimit from 'p-limit'
import pathAbsolute from 'path-absolute'
import pick from 'ramda/src/pick'
import partition from 'ramda/src/partition'
import renderHelp from 'render-help'
import * as installCommand from './install'
import { getSaveType } from './getSaveType'
import * as install from './install'
// @ts-expect-error
const isWindows = process.platform === 'win32' || global['FAKE_WINDOWS']
const isFilespec = isWindows ? /^(?:[.]|~[/]|[/\\]|[a-zA-Z]:)/ : /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/
const installLimit = pLimit(4)
type LinkOpts = CreateStoreControllerOptions & Pick<Config,
type LinkOpts = Pick<Config,
| 'bin'
| 'cliOptions'
| 'engineStrict'
| 'rootProjectManifest'
| 'rootProjectManifestDir'
| 'saveDev'
| 'saveOptional'
| 'saveProd'
| 'workspaceDir'
| 'workspacePackagePatterns'
| 'sharedWorkspaceLockfile'
> & Partial<Pick<Config, 'linkWorkspacePackages'>>
| 'globalPkgDir'
> & Partial<Pick<Config, 'linkWorkspacePackages'>> & install.InstallCommandOptions
export const rcOptionsTypes = cliOptionsTypes
@@ -77,21 +68,13 @@ export function help (): string {
{
title: 'Options',
list: [
...UNIVERSAL_OPTIONS,
{
description: 'Link package to/from global node_modules',
name: '--global',
shortAlias: '-g',
},
],
list: UNIVERSAL_OPTIONS,
},
],
url: docsUrl('link'),
usages: [
'pnpm link <dir>',
'pnpm link --global (in package dir)',
'pnpm link --global <pkg>',
'pnpm link <dir|pkg name>',
'pnpm link',
],
})
}
@@ -121,9 +104,6 @@ export async function handler (
opts: LinkOpts,
params?: string[]
): Promise<void> {
const cwd = process.cwd()
const storeControllerCache = new Map<string, Promise<{ dir: string, ctrl: StoreController }>>()
let workspacePackagesArr: Project[]
let workspacePackages!: WorkspacePackages
if (opts.workspaceDir) {
@@ -136,121 +116,66 @@ export async function handler (
workspacePackages = new Map()
}
const store = await createOrConnectStoreControllerCached(storeControllerCache, opts)
const linkOpts = Object.assign(opts, {
storeController: store.ctrl,
storeDir: store.dir,
targetDependenciesField: getSaveType(opts),
workspacePackages,
binsDir: opts.bin,
})
const linkCwdDir = opts.cliOptions?.dir && opts.cliOptions?.global ? path.resolve(opts.cliOptions.dir) : cwd
// pnpm link
// "pnpm link"
if ((params == null) || (params.length === 0)) {
const cwd = process.cwd()
if (path.relative(linkOpts.dir, cwd) === '') {
throw new PnpmError('LINK_BAD_PARAMS', 'You must provide a parameter')
}
await checkPeerDeps(linkCwdDir, opts)
await checkPeerDeps(cwd, opts)
const { manifest, writeProjectManifest } = await tryReadProjectManifest(opts.dir, opts)
const newManifest = await addDependenciesToPackage(
manifest ?? {},
[`link:${linkCwdDir}`],
linkOpts
)
await writeProjectManifest(newManifest)
const newManifest = opts.rootProjectManifest ?? {}
await addLinkToManifest(opts, newManifest, cwd, linkOpts.dir)
await writeProjectManifest(path.join(opts.rootProjectManifestDir, 'package.json'), newManifest)
await install.handler({
...linkOpts,
frozenLockfileIfExists: false,
rootProjectManifest: newManifest,
})
return
}
const [pkgPaths, pkgNames] = partition((inp) => isFilespec.test(inp), params)
await Promise.all(
pkgPaths.map(async (dir) => installLimit(async () => {
const s = await createOrConnectStoreControllerCached(storeControllerCache, opts)
const config = await getConfig(
{ ...opts.cliOptions, dir },
{
excludeReporter: true,
rcOptionsTypes: installCommand.rcOptionsTypes(),
workspaceDir: await findWorkspaceDir(dir),
}
)
await install(
await readProjectManifestOnly(dir, opts), {
...config,
...getOptionsFromRootManifest(config.rootProjectManifestDir, config.rootProjectManifest ?? {}),
include: {
dependencies: config.production !== false,
devDependencies: config.dev !== false,
optionalDependencies: config.optional !== false,
},
storeController: s.ctrl,
storeDir: s.dir,
workspacePackages,
} as InstallOptions
)
}))
)
if (pkgNames.length > 0) {
let globalPkgNames!: string[]
if (opts.workspaceDir) {
workspacePackagesArr = await findWorkspacePackages(opts.workspaceDir, {
...opts,
patterns: opts.workspacePackagePatterns,
})
const pkgsFoundInWorkspace = workspacePackagesArr
.filter(({ manifest }) => manifest.name && pkgNames.includes(manifest.name))
pkgsFoundInWorkspace.forEach((pkgFromWorkspace) => pkgPaths.push(pkgFromWorkspace.rootDir))
if ((pkgsFoundInWorkspace.length > 0) && !linkOpts.targetDependenciesField) {
linkOpts.targetDependenciesField = 'dependencies'
}
globalPkgNames = pkgNames.filter((pkgName) => !pkgsFoundInWorkspace.some((pkgFromWorkspace) => pkgFromWorkspace.manifest.name === pkgName))
} else {
globalPkgNames = pkgNames
}
const globalPkgPath = pathAbsolute(opts.dir)
globalPkgNames.forEach((pkgName) => pkgPaths.push(path.join(globalPkgPath, 'node_modules', pkgName)))
}
const { manifest, writeProjectManifest } = await readProjectManifest(linkCwdDir, opts)
pkgNames.forEach((pkgName) => pkgPaths.push(path.join(opts.globalPkgDir, 'node_modules', pkgName)))
const newManifest = opts.rootProjectManifest ?? {}
await Promise.all(
pkgPaths.map(async (dir) => {
await addLinkToManifest(opts, newManifest, dir, opts.dir)
await checkPeerDeps(dir, opts)
})
)
const linkConfig = await getConfig(
{ ...opts.cliOptions, dir: cwd },
{
excludeReporter: true,
rcOptionsTypes: installCommand.rcOptionsTypes(),
workspaceDir: await findWorkspaceDir(cwd),
}
)
const storeL = await createOrConnectStoreControllerCached(storeControllerCache, linkConfig)
const newManifest = await link(pkgPaths, path.join(linkCwdDir, 'node_modules'), {
...linkConfig,
targetDependenciesField: linkOpts.targetDependenciesField,
storeController: storeL.ctrl,
storeDir: storeL.dir,
manifest,
} as LinkFunctionOptions)
if (!opts.cliOptions?.global) {
await writeProjectManifest(newManifest)
}
await Promise.all(
Array.from(storeControllerCache.values())
.map(async (storeControllerPromise) => {
const storeControllerHolder = await storeControllerPromise
await storeControllerHolder.ctrl.close()
})
)
await writeProjectManifest(path.join(opts.rootProjectManifestDir, 'package.json'), newManifest)
await install.handler({
...linkOpts,
frozenLockfileIfExists: false,
rootProjectManifest: newManifest,
})
}
async function addLinkToManifest (opts: ReadProjectManifestOpts, manifest: ProjectManifest, linkedDepDir: string, dependentDir: string) {
if (!manifest.pnpm) {
manifest.pnpm = {
overrides: {},
}
} else if (!manifest.pnpm.overrides) {
manifest.pnpm.overrides = {}
}
const { manifest: linkedManifest } = await tryReadProjectManifest(linkedDepDir, opts)
const linkedPkgName = linkedManifest?.name ?? path.basename(linkedDepDir)
const linkedPkgSpec = `link:${path.relative(dependentDir, linkedDepDir)}`
manifest.pnpm.overrides![linkedPkgName] = linkedPkgSpec
if (DEPENDENCIES_FIELDS.every((depField) => manifest[depField]?.[linkedPkgName] == null)) {
manifest.dependencies = manifest.dependencies ?? {}
manifest.dependencies[linkedPkgName] = linkedPkgSpec
}
}

View File

@@ -29,7 +29,6 @@ import {
type InstallOptions,
type MutatedProject,
mutateModules,
type MutateModulesResult,
type ProjectOptions,
type UpdateMatchingFunction,
type WorkspacePackages,
@@ -103,7 +102,7 @@ export async function recursive (
allProjects: Project[],
params: string[],
opts: RecursiveOptions,
cmdFullName: 'install' | 'add' | 'remove' | 'unlink' | 'update' | 'import'
cmdFullName: 'install' | 'add' | 'remove' | 'update' | 'import'
): Promise<boolean | string> {
if (allProjects.length === 0) {
// It might make sense to throw an exception in this case
@@ -121,9 +120,7 @@ export async function recursive (
const store = await createOrConnectStoreController(opts)
const workspacePackages: WorkspacePackages = cmdFullName !== 'unlink'
? arrayOfWorkspacePackagesToMap(allProjects) as WorkspacePackages
: new Map()
const workspacePackages: WorkspacePackages = arrayOfWorkspacePackagesToMap(allProjects) as WorkspacePackages
const targetDependenciesField = getSaveType(opts)
const rootManifestDir = (opts.lockfileDir ?? opts.dir) as ProjectRootDir
const installOpts = Object.assign(opts, {
@@ -313,9 +310,6 @@ export async function recursive (
let action!: any // eslint-disable-line @typescript-eslint/no-explicit-any
switch (cmdFullName) {
case 'unlink':
action = (currentInput.length === 0 ? unlink : unlinkPkgs.bind(null, currentInput))
break
case 'remove':
action = async (manifest: PackageManifest, opts: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
const mutationResult = await mutateModules([
@@ -385,8 +379,7 @@ export async function recursive (
!opts.lockfileOnly && !opts.ignoreScripts && (
cmdFullName === 'add' ||
cmdFullName === 'install' ||
cmdFullName === 'update' ||
cmdFullName === 'unlink'
cmdFullName === 'update'
)
) {
await rebuild.handler({
@@ -406,31 +399,6 @@ export async function recursive (
return true
}
async function unlink (manifest: ProjectManifest, opts: any): Promise<MutateModulesResult> { // eslint-disable-line @typescript-eslint/no-explicit-any
return mutateModules(
[
{
mutation: 'unlink',
rootDir: opts.dir,
},
],
opts
)
}
async function unlinkPkgs (dependencyNames: string[], manifest: ProjectManifest, opts: any): Promise<MutateModulesResult> { // eslint-disable-line @typescript-eslint/no-explicit-any
return mutateModules(
[
{
dependencyNames,
mutation: 'unlinkSome',
rootDir: opts.dir,
},
],
opts
)
}
function calculateRepositoryRoot (
workspaceDir: string,
projectDirs: string[]

View File

@@ -1,14 +1,13 @@
import { docsUrl, readProjectManifestOnly } from '@pnpm/cli-utils'
import path from 'path'
import { docsUrl } from '@pnpm/cli-utils'
import { UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { type Config, getOptionsFromRootManifest } from '@pnpm/config'
import { createOrConnectStoreController, type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
import { mutateModulesInSingleProject } from '@pnpm/core'
import { type ProjectRootDir } from '@pnpm/types'
import { writeProjectManifest } from '@pnpm/write-project-manifest'
import renderHelp from 'render-help'
import { cliOptionsTypes, rcOptionsTypes } from './install'
import { recursive } from './recursive'
import * as install from './install'
export { cliOptionsTypes, rcOptionsTypes }
export const cliOptionsTypes = install.cliOptionsTypes
export const rcOptionsTypes = install.rcOptionsTypes
export const commandNames = ['unlink', 'dislink']
@@ -41,57 +40,25 @@ For options that may be used with `-r`, see "pnpm help recursive"',
}
export async function handler (
opts: CreateStoreControllerOptions &
Pick<Config,
| 'allProjects'
| 'allProjectsGraph'
| 'bail'
| 'bin'
| 'engineStrict'
| 'hooks'
| 'linkWorkspacePackages'
| 'saveWorkspaceProtocol'
| 'selectedProjectsGraph'
| 'rawLocalConfig'
| 'registries'
| 'rootProjectManifest'
| 'rootProjectManifestDir'
| 'pnpmfile'
| 'workspaceDir'
> & {
recursive?: boolean
},
opts: install.InstallCommandOptions,
params: string[]
): Promise<void> {
if (opts.recursive && (opts.allProjects != null) && (opts.selectedProjectsGraph != null) && opts.workspaceDir) {
await recursive(opts.allProjects, params, {
...opts,
allProjectsGraph: opts.allProjectsGraph!,
selectedProjectsGraph: opts.selectedProjectsGraph,
workspaceDir: opts.workspaceDir,
}, 'unlink')
return
}
const store = await createOrConnectStoreController(opts)
const unlinkOpts = Object.assign(opts, {
...getOptionsFromRootManifest(opts.rootProjectManifestDir, opts.rootProjectManifest ?? {}),
globalBin: opts.bin,
storeController: store.ctrl,
storeDir: store.dir,
})
): Promise<undefined | string> {
if (!opts.rootProjectManifest?.pnpm?.overrides) return 'Nothing to unlink'
if (!params || (params.length === 0)) {
await mutateModulesInSingleProject({
dependencyNames: params,
manifest: await readProjectManifestOnly(opts.dir, opts),
mutation: 'unlinkSome',
rootDir: opts.dir as ProjectRootDir,
}, unlinkOpts)
return
for (const selector in opts.rootProjectManifest.pnpm.overrides) {
if (opts.rootProjectManifest.pnpm.overrides[selector].startsWith('link:')) {
delete opts.rootProjectManifest.pnpm.overrides[selector]
}
}
} else {
for (const selector in opts.rootProjectManifest.pnpm.overrides) {
if (opts.rootProjectManifest.pnpm.overrides[selector].startsWith('link:') && params.includes(selector)) {
delete opts.rootProjectManifest.pnpm.overrides[selector]
}
}
}
await mutateModulesInSingleProject({
manifest: await readProjectManifestOnly(opts.dir, opts),
mutation: 'unlink',
rootDir: opts.dir as ProjectRootDir,
}, unlinkOpts)
await writeProjectManifest(path.join(opts.rootProjectManifestDir, 'package.json'), opts.rootProjectManifest)
await install.handler(opts)
return undefined
}

View File

@@ -1,9 +1,8 @@
import fs from 'fs'
import path from 'path'
import { sync as readYamlFile } from 'read-yaml-file'
import { install, link } from '@pnpm/plugin-commands-installation'
import { prepare, preparePackages } from '@pnpm/prepare'
import { assertProject, isExecutable } from '@pnpm/assert-project'
import { isExecutable } from '@pnpm/assert-project'
import { fixtures } from '@pnpm/test-fixtures'
import { logger } from '@pnpm/logger'
import { sync as loadJsonFile } from 'load-json-file'
@@ -25,28 +24,27 @@ test('linking multiple packages', async () => {
process.chdir('linked-foo')
console.log('linking linked-foo to global package')
const linkOpts = {
// linking linked-foo to global package
await link.handler({
...DEFAULT_OPTS,
bin: path.join(globalDir, 'bin'),
dir: globalDir,
}
await link.handler({
...linkOpts,
globalPkgDir: globalDir,
rootProjectManifestDir: globalDir,
})
process.chdir('..')
process.chdir('project')
await link.handler({
...linkOpts,
...DEFAULT_OPTS,
dir: process.cwd(),
globalPkgDir: globalDir,
rootProjectManifestDir: process.cwd(),
}, ['linked-foo', '../linked-bar'])
project.has('linked-foo')
project.has('linked-bar')
const modules = readYamlFile<any>('../linked-bar/node_modules/.modules.yaml') // eslint-disable-line @typescript-eslint/no-explicit-any
expect(modules.hoistPattern).toStrictEqual(['*']) // the linked package used its own configs during installation // eslint-disable-line @typescript-eslint/dot-notation
})
test('link global bin', async function () {
@@ -66,11 +64,10 @@ test('link global bin', async function () {
await link.handler({
...DEFAULT_OPTS,
cliOptions: {
global: true,
},
bin: globalBin,
dir: globalDir,
globalPkgDir: globalDir,
rootProjectManifestDir: globalDir,
})
process.env[PATH] = oldPath
@@ -79,35 +76,6 @@ test('link global bin', async function () {
}, path.join(globalBin, 'package-with-bin'))
})
test('link to global bin from the specified directory', async function () {
prepare()
process.chdir('..')
const globalDir = path.resolve('global')
const globalBin = path.join(globalDir, 'bin')
const oldPath = process.env[PATH]
process.env[PATH] = `${globalBin}${path.delimiter}${oldPath ?? ''}`
fs.mkdirSync(globalBin, { recursive: true })
await writePkg('./dir/package-with-bin-in-dir', { name: 'package-with-bin-in-dir', version: '1.0.0', bin: 'bin.js' })
fs.writeFileSync('./dir/package-with-bin-in-dir/bin.js', '#!/usr/bin/env node\nconsole.log(/hi/)\n', 'utf8')
await link.handler({
...DEFAULT_OPTS,
cliOptions: {
global: true,
dir: path.resolve('./dir/package-with-bin-in-dir'),
},
bin: globalBin,
dir: globalDir,
})
process.env[PATH] = oldPath
isExecutable((value) => {
expect(value).toBeTruthy()
}, path.join(globalBin, 'package-with-bin-in-dir'))
})
test('link a global package to the specified directory', async function () {
const project = prepare({ dependencies: { 'global-package-with-bin': '0.0.0' } })
process.chdir('..')
@@ -126,11 +94,10 @@ test('link a global package to the specified directory', async function () {
// link to global
await link.handler({
...DEFAULT_OPTS,
cliOptions: {
global: true,
},
bin: globalBin,
dir: globalDir,
globalPkgDir: globalDir,
rootProjectManifestDir: globalDir,
})
process.chdir('..')
@@ -139,13 +106,12 @@ test('link a global package to the specified directory', async function () {
// link from global
await link.handler({
...DEFAULT_OPTS,
cliOptions: {
global: true,
dir: projectDir,
},
bin: globalBin,
dir: globalDir,
// bin: globalBin,
dir: projectDir,
saveProd: true, // @pnpm/config sets this setting to true when global is true. This should probably be changed.
globalPkgDir: globalDir,
rootProjectManifest: { dependencies: { 'global-package-with-bin': '0.0.0' } },
rootProjectManifestDir: projectDir,
}, ['global-package-with-bin'])
process.env[PATH] = oldPath
@@ -169,19 +135,21 @@ test('relative link', async () => {
await link.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
globalPkgDir: '',
rootProjectManifest: {
dependencies: {
'@pnpm.e2e/hello-world-js-bin': '*',
},
},
rootProjectManifestDir: process.cwd(),
}, [`../${linkedPkgName}`])
project.isExecutable('.bin/hello-world-js-bin')
// The linked package has been installed successfully as well with bins linked
// to node_modules/.bin
const linkedProject = assertProject(linkedPkgPath)
linkedProject.isExecutable('.bin/cowsay')
const wantedLockfile = project.readLockfile()
expect(wantedLockfile.importers['.'].dependencies?.['@pnpm.e2e/hello-world-js-bin']).toStrictEqual({
specifier: '*', // specifier of linked dependency added to ${WANTED_LOCKFILE}
version: 'link:../hello-world-js-bin', // link added to wanted lockfile
specifier: 'link:../hello-world-js-bin',
version: 'link:../hello-world-js-bin',
})
const currentLockfile = project.readCurrentLockfile()
@@ -202,18 +170,20 @@ test('absolute link', async () => {
await link.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
globalPkgDir: '',
rootProjectManifestDir: process.cwd(),
rootProjectManifest: {
dependencies: {
'@pnpm.e2e/hello-world-js-bin': '*',
},
},
}, [linkedPkgPath])
project.isExecutable('.bin/hello-world-js-bin')
// The linked package has been installed successfully as well with bins linked
// to node_modules/.bin
const linkedProject = assertProject(linkedPkgPath)
linkedProject.isExecutable('.bin/cowsay')
const wantedLockfile = project.readLockfile()
expect(wantedLockfile.importers['.'].dependencies?.['@pnpm.e2e/hello-world-js-bin']).toStrictEqual({
specifier: '*', // specifier of linked dependency added to ${WANTED_LOCKFILE}
specifier: 'link:../hello-world-js-bin', // specifier of linked dependency added to ${WANTED_LOCKFILE}
version: 'link:../hello-world-js-bin', // link added to wanted lockfile
})
@@ -222,18 +192,19 @@ test('absolute link', async () => {
})
test('link --production', async () => {
const projects = preparePackages([
{
name: 'target',
version: '1.0.0',
const targetManifest = {
name: 'target',
version: '1.0.0',
dependencies: {
'is-positive': '1.0.0',
},
devDependencies: {
'is-negative': '1.0.0',
},
dependencies: {
'is-positive': '1.0.0',
},
devDependencies: {
'is-negative': '1.0.0',
},
}
const projects = preparePackages([
targetManifest,
{
name: 'source',
version: '1.0.0',
@@ -257,11 +228,11 @@ test('link --production', async () => {
...DEFAULT_OPTS,
cliOptions: { production: true },
dir: process.cwd(),
globalPkgDir: '',
rootProjectManifestDir: process.cwd(),
rootProjectManifest: targetManifest,
}, ['../source'])
projects['source'].has('is-positive')
projects['source'].hasNot('is-negative')
// --production should not have effect on the target
projects['target'].has('is-positive')
projects['target'].has('is-negative')
@@ -274,6 +245,7 @@ test('link fails if nothing is linked', async () => {
link.handler({
...DEFAULT_OPTS,
dir: '',
globalPkgDir: '',
}, [])
).rejects.toThrow(/You must provide a parameter/)
})
@@ -296,20 +268,21 @@ test('logger warns about peer dependencies when linking', async () => {
process.chdir('linked-with-peer-deps')
const linkOpts = {
await link.handler({
...DEFAULT_OPTS,
bin: path.join(globalDir, 'bin'),
dir: globalDir,
}
await link.handler({
...linkOpts,
globalPkgDir: globalDir,
rootProjectManifestDir: globalDir,
})
process.chdir('..')
process.chdir('project')
await link.handler({
...linkOpts,
...DEFAULT_OPTS,
dir: process.cwd(),
globalPkgDir: globalDir,
}, ['linked-with-peer-deps'])
expect(warnMock).toHaveBeenCalledWith(expect.objectContaining({
@@ -335,20 +308,22 @@ test('logger should not warn about peer dependencies when it is an empty object'
process.chdir('linked-with-empty-peer-deps')
const linkOpts = {
await link.handler({
...DEFAULT_OPTS,
globalPkgDir: '',
bin: path.join(globalDir, 'bin'),
dir: globalDir,
}
await link.handler({
...linkOpts,
rootProjectManifestDir: globalDir,
})
process.chdir('..')
process.chdir('project')
await link.handler({
...linkOpts,
...DEFAULT_OPTS,
globalPkgDir: globalDir,
dir: process.cwd(),
rootProjectManifestDir: process.cwd(),
}, ['linked-with-empty-peer-deps'])
expect(warnMock).not.toHaveBeenCalledWith(expect.objectContaining({

View File

@@ -1,137 +0,0 @@
import fs from 'fs'
import path from 'path'
import { LOCKFILE_VERSION } from '@pnpm/constants'
import { filterPackagesFromDir } from '@pnpm/workspace.filter-packages-from-dir'
import { install, unlink } from '@pnpm/plugin-commands-installation'
import { preparePackages } from '@pnpm/prepare'
import { DEFAULT_OPTS } from './utils'
test('recursive linking/unlinking', async () => {
const projects = preparePackages([
{
name: 'project-1',
version: '1.0.0',
devDependencies: {
'is-positive': '1.0.0',
},
},
{
name: 'is-positive',
version: '1.0.0',
dependencies: {
'is-negative': '1.0.0',
},
},
])
const { allProjects, allProjectsGraph, selectedProjectsGraph } = await filterPackagesFromDir(process.cwd(), [])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
dir: process.cwd(),
recursive: true,
saveWorkspaceProtocol: false,
selectedProjectsGraph,
workspaceDir: process.cwd(),
})
expect(projects['is-positive'].requireModule('is-negative')).toBeTruthy()
expect(projects['project-1'].requireModule('is-positive/package.json').author).toBeFalsy()
{
const project1Lockfile = projects['project-1'].readLockfile()
expect(project1Lockfile.importers['.'].devDependencies?.['is-positive'].version).toBe('link:../is-positive')
}
await unlink.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
dir: process.cwd(),
recursive: true,
saveWorkspaceProtocol: false,
selectedProjectsGraph,
workspaceDir: process.cwd(),
}, [])
process.chdir('project-1')
expect(fs.existsSync(path.resolve('node_modules', 'is-positive', 'index.js'))).toBeTruthy()
{
const project1Lockfile = projects['project-1'].readLockfile()
expect(project1Lockfile.lockfileVersion).toBe(LOCKFILE_VERSION)
expect(project1Lockfile.importers['.'].devDependencies?.['is-positive'].version).toBe('1.0.0')
expect(project1Lockfile.packages['is-positive@1.0.0']).toBeTruthy()
}
const isPositiveLockfile = projects['is-positive'].readLockfile()
expect(isPositiveLockfile.lockfileVersion).toBe(LOCKFILE_VERSION)
})
test('recursive unlink specific package', async () => {
const projects = preparePackages([
{
name: 'project-1',
version: '1.0.0',
devDependencies: {
'is-positive': '1.0.0',
},
},
{
name: 'is-positive',
version: '1.0.0',
dependencies: {
'is-negative': '1.0.0',
},
},
])
const { allProjects, allProjectsGraph, selectedProjectsGraph } = await filterPackagesFromDir(process.cwd(), [])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
dir: process.cwd(),
recursive: true,
saveWorkspaceProtocol: false,
selectedProjectsGraph,
workspaceDir: process.cwd(),
})
expect(projects['is-positive'].requireModule('is-negative')).toBeTruthy()
expect(projects['project-1'].requireModule('is-positive/package.json').author).toBeFalsy()
{
const project1Lockfile = projects['project-1'].readLockfile()
expect(project1Lockfile.importers['.'].devDependencies?.['is-positive'].version).toBe('link:../is-positive')
}
await unlink.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
dir: process.cwd(),
recursive: true,
saveWorkspaceProtocol: false,
selectedProjectsGraph,
workspaceDir: process.cwd(),
}, ['is-positive'])
process.chdir('project-1')
expect(fs.existsSync(path.resolve('node_modules', 'is-positive', 'index.js'))).toBeTruthy()
{
const project1Lockfile = projects['project-1'].readLockfile()
expect(project1Lockfile.lockfileVersion).toBe(LOCKFILE_VERSION)
expect(project1Lockfile.importers['.'].devDependencies?.['is-positive'].version).toBe('1.0.0')
expect(project1Lockfile.packages['is-positive@1.0.0']).toBeTruthy()
}
const isPositiveLockfile = projects['is-positive'].readLockfile()
expect(isPositiveLockfile.lockfileVersion).toBe(LOCKFILE_VERSION)
})

View File

@@ -1,9 +1,10 @@
import path from 'path'
import { add, install, link, prune } from '@pnpm/plugin-commands-installation'
import { add, install, prune } from '@pnpm/plugin-commands-installation'
import { prepare } from '@pnpm/prepare'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { fixtures } from '@pnpm/test-fixtures'
import { createTestIpcServer } from '@pnpm/test-ipc-server'
import symlinkDir from 'symlink-dir'
import fs from 'fs'
const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}`
@@ -44,12 +45,7 @@ test('prune removes external link that is not in package.json', async () => {
const storeDir = path.resolve('store')
f.copy('local-pkg', 'local')
await link.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
storeDir,
}, ['./local'])
symlinkDir.sync(path.resolve('local'), path.join('node_modules/local-pkg'))
project.has('local-pkg')

View File

@@ -75,6 +75,9 @@
{
"path": "../../pkg-manifest/read-project-manifest"
},
{
"path": "../../pkg-manifest/write-project-manifest"
},
{
"path": "../../resolving/resolver-base"
},

6
pnpm-lock.yaml generated
View File

@@ -5054,6 +5054,9 @@ importers:
'@pnpm/workspace.pkgs-graph':
specifier: workspace:*
version: link:../../workspace/pkgs-graph
'@pnpm/write-project-manifest':
specifier: workspace:*
version: link:../../pkg-manifest/write-project-manifest
'@yarnpkg/core':
specifier: 'catalog:'
version: 4.0.5(typanion@3.14.0)
@@ -5093,9 +5096,6 @@ importers:
p-limit:
specifier: 'catalog:'
version: 3.1.0
path-absolute:
specifier: 'catalog:'
version: 1.0.1
ramda:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'

View File

@@ -76,47 +76,6 @@ test('incorrect workspace manifest', async () => {
expect(status).toBe(1)
})
test('linking a package inside a monorepo', async () => {
const projects = preparePackages([
{
name: 'project-1',
version: '1.0.0',
},
{
name: 'project-2',
version: '2.0.0',
},
{
name: 'project-3',
version: '3.0.0',
},
{
name: 'project-4',
version: '4.0.0',
},
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
process.chdir('project-1')
await execPnpm(['link', 'project-2'])
await execPnpm(['link', 'project-3', '--save-dev'])
await execPnpm(['link', 'project-4', '--save-optional'])
const { default: pkg } = await import(path.resolve('package.json'))
expect(pkg?.dependencies).toStrictEqual({ 'project-2': '^2.0.0' }) // spec of linked package added to dependencies
expect(pkg?.devDependencies).toStrictEqual({ 'project-3': '^3.0.0' }) // spec of linked package added to devDependencies
expect(pkg?.optionalDependencies).toStrictEqual({ 'project-4': '^4.0.0' }) // spec of linked package added to optionalDependencies
projects['project-1'].has('project-2')
projects['project-1'].has('project-3')
projects['project-1'].has('project-4')
})
test('linking a package inside a monorepo with --link-workspace-packages when installing new dependencies', async () => {
const projects = preparePackages([
{