feat(deploy): create dedicated lockfile (#8828)

* feat(deploy): create dedicated lockfile

Closes https://github.com/pnpm/pnpm/issues/8778

* chore: remove a leftover `console.log`

* fix: hoisted should also use dedicated lockfile

* feat: inherit more keys

* docs(changeset): more details

* refactor: remove a variable

* refactor: use `selectedProject.rootDir`

* fix: manifest files

* fix: update lockfile

* fix: accidentally skipped normal dependencies

* test: update

* fix: meta

* fix: remove links to nowhere

* docs: remove the false todo

* fix: transitive workspace dependencies

* fix: package snapshot names

* fix: dependencies that depend on deployed package

* perf: do not repeat computation

* fix: compile error

* refactor: base on allProjects

* fix: add missing `link:` prefix

* test: add some tests

* fix: revert stupid refactor

This reverts commit 000788127c.

* test: more assertions

* test: more assertions

* test: https://github.com/pnpm/pnpm/issues/8778

* test: exact paths

* refactor: use `toBe`

* refactor: divide section

* fix: eslint

* test: fix assertions

* fix: dependencies that depend on deploy package

* perf: cheap operation first

* test: remove `.only`

* test: add assertions

* test: remove unnecessary assertions

* test: remove unnecessary details

* fix: deployed package depends on itself

* docs: remove the other todo

* fix: self-referential dependencies

* test: fix

* test: more assertions

* feat: convert fallbacks to programmer errors

* fix: `file:` protocol

* refactor: more types

* refactor: remove unused variables

* refactor: fix regex

* feat: force-legacy-deploy

* feat: suggest reporting bug and using workaround

* feat: overrides, patchedDependencies, packageExtensions (wip)

* test: fix

* feat: handle `packageExtensions` in a smarter way

* fix: pnpmfile

* docs: change wording

* fix: `packageExtensions` with internal dependencies

* fix: directory resolution location

* refactor: use `rootProjectManifestDir`

* feat: set `overrides` to `undefined` instead

* refactor: remove `as ProjectRootDirRealPath`

* test: packageExtensions

* test: use regex string matchers

* refactor: move new tests to its own file

* fix: patchedDependencies

* fix: eslint

* test: patchedDependencies

* test: fix windows

* fix: pnpmfile checksum

* docs: change wording

* fix: peer dependencies

* docs: omission of peers

* docs: more detailed explanation

* fix: preserve unique peer dependencies suffix

* refactor: code rearrange

* refactor: shorten lines of code

* feat: add `dedupeInjectedDeps` to `InstallCommandOptions`

* test: peer dependencies suffix

* docs(changeset): config -> force-legacy-deploy

* docs(changeset): merge

* docs(changeset): add missing period
This commit is contained in:
Khải
2024-12-27 23:17:12 +07:00
committed by GitHub
parent 09c1ffbae5
commit f89128883f
15 changed files with 1503 additions and 64 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/config": minor
"@pnpm/plugin-commands-deploy": minor
"pnpm": minor
---
`pnpm deploy` now tries creating a dedicated lockfile from a shared lockfile for deployment. It will fallback to deployment without a lockfile if there is no shared lockfile or `force-legacy-deploy` is set to `true`.

View File

@@ -0,0 +1,6 @@
---
"@pnpm/plugin-commands-deploy": patch
"pnpm": patch
---
Fix an issue in which `pnpm deploy --prod` fails due to missing `devDependencies` [#8778](https://github.com/pnpm/pnpm/issues/8778).

View File

@@ -0,0 +1,5 @@
---
"@pnpm/plugin-commands-installation": minor
---
Add `dedupeInjectedDeps` to `InstallCommandOptions`.

View File

@@ -102,6 +102,7 @@ export interface Config {
failedToLoadBuiltInConfig: boolean
resolvePeersFromWorkspaceRoot?: boolean
deployAllFiles?: boolean
forceLegacyDeploy?: boolean
reporterHidePrefix?: boolean
// proxy

View File

@@ -132,6 +132,7 @@ export async function getConfig (opts: {
'fetch-retry-maxtimeout': 60000,
'fetch-retry-mintimeout': 10000,
'fetch-timeout': 60000,
'force-legacy-deploy': false,
'git-shallow-hosts': [
// Follow https://github.com/npm/git/blob/1e1dbd26bd5b87ca055defecc3679777cb480e2a/lib/clone.js#L13-L19
'github.com',

View File

@@ -24,6 +24,7 @@ export const types = Object.assign({
'fetching-concurrency': Number,
filter: [String, Array],
'filter-prod': [String, Array],
'force-legacy-deploy': Boolean,
'frozen-lockfile': Boolean,
'git-checks': Boolean,
'git-shallow-hosts': Array,

View File

@@ -254,6 +254,7 @@ export type InstallCommandOptions = Pick<Config,
| 'bin'
| 'catalogs'
| 'cliOptions'
| 'dedupeInjectedDeps'
| 'dedupeDirectDeps'
| 'dedupePeerDependents'
| 'deployAllFiles'

73
pnpm-lock.yaml generated
View File

@@ -6126,6 +6126,9 @@ importers:
'@pnpm/common-cli-options-help':
specifier: workspace:*
version: link:../../cli/common-cli-options-help
'@pnpm/config':
specifier: workspace:*
version: link:../../config/config
'@pnpm/directory-fetcher':
specifier: workspace:*
version: link:../../fetching/directory-fetcher
@@ -6138,15 +6141,30 @@ importers:
'@pnpm/fs.is-empty-dir-or-nothing':
specifier: workspace:*
version: link:../../fs/is-empty-dir-or-nothing
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../lockfile/fs
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/plugin-commands-installation':
specifier: workspace:*
version: link:../../pkg-manager/plugin-commands-installation
'@pnpm/types':
specifier: workspace:*
version: link:../../packages/types
'@types/normalize-path':
specifier: 'catalog:'
version: 3.0.2
'@zkochan/rimraf':
specifier: 'catalog:'
version: 3.0.2
normalize-path:
specifier: 'catalog:'
version: 3.0.0
ramda:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'
render-help:
specifier: 'catalog:'
version: 1.0.3
@@ -6154,9 +6172,6 @@ importers:
'@pnpm/assert-project':
specifier: workspace:*
version: link:../../__utils__/assert-project
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/logger':
specifier: workspace:*
version: link:../../packages/logger
@@ -6169,9 +6184,15 @@ importers:
'@pnpm/registry-mock':
specifier: 'catalog:'
version: 3.46.0(encoding@0.1.13)(typanion@3.14.0)
'@pnpm/test-fixtures':
specifier: workspace:*
version: link:../../__utils__/test-fixtures
'@pnpm/workspace.filter-packages-from-dir':
specifier: workspace:*
version: link:../../workspace/filter-packages-from-dir
'@types/ramda':
specifier: 'catalog:'
version: 0.29.12
releasing/plugin-commands-publishing:
dependencies:
@@ -8715,7 +8736,6 @@ packages:
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
deprecated: Use @eslint/config-array instead
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@@ -8723,7 +8743,6 @@ packages:
'@humanwhocodes/object-schema@2.0.3':
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
@@ -9584,10 +9603,6 @@ packages:
peerDependencies:
'@yarnpkg/core': ^4.0.5
'@yarnpkg/fslib@3.1.0':
resolution: {integrity: sha512-wsj7/sUVSdXOIX/qwaON/Ky5GsP5gs9ry9DKwgLbWT7k3qw4/EcHAtfTtPhBYu33UibzBFI+fgB4wBRVH2XVaw==}
engines: {node: '>=18.12.0'}
'@yarnpkg/fslib@3.1.1':
resolution: {integrity: sha512-NpeecISQEuDnmipElGa0cOC7DnlPf3+FXnuwwJTciJgt+S/BDb8VFBvXSE5UirGmsFWlf4mfZuuAC7e8Pmhh4g==}
engines: {node: '>=18.12.0'}
@@ -9626,11 +9641,6 @@ packages:
engines: {node: '>=18.12.0'}
hasBin: true
'@yarnpkg/shell@4.0.2':
resolution: {integrity: sha512-DLZSx06OoEbPY1uePt7pKEgpWDk96PldrCdWBPqI5Np5/YAEo6+toVcjz+6fORMOE8PS3Bsep1Nfm2mUrY1Oxg==}
engines: {node: '>=18.12.0'}
hasBin: true
'@yarnpkg/shell@4.1.1':
resolution: {integrity: sha512-0aS71iJrNQ4cezU5BJ5JpBTXkFQPKkzOEpDtMQm8E2H3g9PLxUe/5VdA60bZq/4N/qazLLYEOngcFZ6QRpraVQ==}
engines: {node: '>=18.12.0'}
@@ -10794,7 +10804,6 @@ packages:
eslint-config-standard-with-typescript@39.1.1:
resolution: {integrity: sha512-t6B5Ep8E4I18uuoYeYxINyqcXb2UbC0SOOTxRtBSt2JUs+EzeXbfe2oaiPs71AIdnoWhXDO2fYOHz8df3kV84A==}
deprecated: Please use eslint-config-love, instead.
peerDependencies:
'@typescript-eslint/eslint-plugin': ^6.4.0
eslint: ^8.0.1
@@ -10899,7 +10908,6 @@ packages:
eslint@8.57.0:
resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
esm@3.2.25:
@@ -11316,7 +11324,6 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
@@ -11582,7 +11589,6 @@ packages:
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -16687,10 +16693,10 @@ snapshots:
'@arcanis/slice-ansi': 1.1.1
'@types/semver': 7.5.8
'@types/treeify': 1.0.3
'@yarnpkg/fslib': 3.1.0
'@yarnpkg/libzip': 3.1.0(@yarnpkg/fslib@3.1.0)
'@yarnpkg/fslib': 3.1.1
'@yarnpkg/libzip': 3.1.0(@yarnpkg/fslib@3.1.1)
'@yarnpkg/parsers': 3.0.2
'@yarnpkg/shell': 4.0.2(typanion@3.14.0)
'@yarnpkg/shell': 4.1.1(typanion@3.14.0)
camelcase: 5.3.1
chalk: 3.0.0
ci-info: 3.9.0
@@ -16748,20 +16754,10 @@ snapshots:
dependencies:
'@yarnpkg/core': 4.0.5(typanion@3.14.0)
'@yarnpkg/fslib@3.1.0':
dependencies:
tslib: 2.7.0
'@yarnpkg/fslib@3.1.1':
dependencies:
tslib: 2.7.0
'@yarnpkg/libzip@3.1.0(@yarnpkg/fslib@3.1.0)':
dependencies:
'@types/emscripten': 1.39.13
'@yarnpkg/fslib': 3.1.0
tslib: 2.7.0
'@yarnpkg/libzip@3.1.0(@yarnpkg/fslib@3.1.1)':
dependencies:
'@types/emscripten': 1.39.13
@@ -16791,7 +16787,7 @@ snapshots:
'@yarnpkg/pnp@4.0.6':
dependencies:
'@types/node': 18.19.49
'@yarnpkg/fslib': 3.1.0
'@yarnpkg/fslib': 3.1.1
'@yarnpkg/pnp@4.0.7':
dependencies:
@@ -16811,19 +16807,6 @@ snapshots:
transitivePeerDependencies:
- typanion
'@yarnpkg/shell@4.0.2(typanion@3.14.0)':
dependencies:
'@yarnpkg/fslib': 3.1.0
'@yarnpkg/parsers': 3.0.2
chalk: 3.0.0
clipanion: 3.2.0-rc.6(typanion@3.14.0)
cross-spawn: 7.0.5
fast-glob: 3.3.2
micromatch: 4.0.8
tslib: 2.7.0
transitivePeerDependencies:
- typanion
'@yarnpkg/shell@4.1.1(typanion@3.14.0)':
dependencies:
'@yarnpkg/fslib': 3.1.1

View File

@@ -35,25 +35,32 @@
"homepage": "https://github.com/pnpm/pnpm/blob/main/releasing/plugin-commands-deploy#readme",
"devDependencies": {
"@pnpm/assert-project": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/logger": "workspace:*",
"@pnpm/plugin-commands-deploy": "workspace:*",
"@pnpm/prepare": "workspace:*",
"@pnpm/registry-mock": "catalog:",
"@pnpm/workspace.filter-packages-from-dir": "workspace:*"
"@pnpm/test-fixtures": "workspace:*",
"@pnpm/workspace.filter-packages-from-dir": "workspace:*",
"@types/ramda": "catalog:"
},
"dependencies": {
"@pnpm/catalogs.resolver": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/cli-utils": "workspace:*",
"@pnpm/config": "workspace:*",
"@pnpm/common-cli-options-help": "workspace:*",
"@pnpm/directory-fetcher": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fs.indexed-pkg-importer": "workspace:*",
"@pnpm/fs.is-empty-dir-or-nothing": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/plugin-commands-installation": "workspace:*",
"@pnpm/types": "workspace:*",
"@types/normalize-path": "catalog:",
"@zkochan/rimraf": "catalog:",
"normalize-path": "catalog:",
"ramda": "catalog:",
"render-help": "catalog:"
},
"peerDependencies": {

View File

@@ -0,0 +1,308 @@
import path from 'path'
import url from 'url'
import normalizePath from 'normalize-path'
import pick from 'ramda/src/pick'
import {
type DirectoryResolution,
type LockfileObject,
type LockfileResolution,
type PackageSnapshot,
type PackageSnapshots,
type ProjectSnapshot,
type ResolvedDependencies,
} from '@pnpm/lockfile.types'
import {
type DependenciesField,
type DepPath,
type Project,
type ProjectId,
type ProjectManifest,
} from '@pnpm/types'
const DEPENDENCIES_FIELD = ['dependencies', 'devDependencies', 'optionalDependencies'] as const satisfies DependenciesField[]
const INHERITED_MANIFEST_KEYS = [
'name',
'description',
'version',
'private',
'author',
'bin',
'scripts',
'packageManager',
'dependenciesMeta',
'peerDependenciesMeta',
] as const satisfies Array<keyof ProjectManifest>
export type DeployManifest = Pick<ProjectManifest, typeof INHERITED_MANIFEST_KEYS[number] | DependenciesField | 'pnpm'>
export interface CreateDeployFilesOptions {
allProjects: Array<Pick<Project, 'manifest' | 'rootDirRealPath'>>
deployDir: string
lockfile: LockfileObject
lockfileDir: string
manifest: DeployManifest
projectId: ProjectId
rootProjectManifestDir: string
}
export interface DeployFiles {
lockfile: LockfileObject
manifest: DeployManifest
}
export function createDeployFiles ({
allProjects,
deployDir,
lockfile,
lockfileDir,
manifest,
projectId,
rootProjectManifestDir,
}: CreateDeployFilesOptions): DeployFiles {
const deployedProjectRealPath = path.resolve(lockfileDir, projectId)
const inputSnapshot = lockfile.importers[projectId]
const targetSnapshot: ProjectSnapshot = {
...inputSnapshot,
specifiers: {},
dependencies: {},
devDependencies: {},
optionalDependencies: {},
}
const targetPackageSnapshots: PackageSnapshots = {}
for (const name in lockfile.packages) {
const inputDepPath = name as DepPath
const inputSnapshot = lockfile.packages[inputDepPath]
const resolveResult = resolveLinkOrFile(inputDepPath, {
lockfileDir,
projectRootDirRealPath: rootProjectManifestDir,
})
const outputDepPath = resolveResult
? createFileUrlDepPath(resolveResult, allProjects)
: inputDepPath
targetPackageSnapshots[outputDepPath] = convertPackageSnapshot(inputSnapshot, {
allProjects,
deployDir,
deployedProjectRealPath,
lockfileDir,
projectRootDirRealPath: rootProjectManifestDir,
})
}
for (const importerPath in lockfile.importers) {
if (importerPath === projectId) continue
const projectSnapshot = lockfile.importers[importerPath as ProjectId]
const projectRootDirRealPath = path.resolve(lockfileDir, importerPath)
const packageSnapshot = convertProjectSnapshotToPackageSnapshot(projectSnapshot, {
allProjects,
deployDir,
lockfileDir,
deployedProjectRealPath,
projectRootDirRealPath,
})
const depPath = createFileUrlDepPath({ resolvedPath: projectRootDirRealPath }, allProjects)
targetPackageSnapshots[depPath] = packageSnapshot
}
for (const field of DEPENDENCIES_FIELD) {
const targetDependencies = targetSnapshot[field] ?? {}
const targetSpecifiers = targetSnapshot.specifiers
const inputDependencies = inputSnapshot[field] ?? {}
for (const name in inputDependencies) {
const spec = inputDependencies[name]
const resolveResult = resolveLinkOrFile(spec, {
lockfileDir,
projectRootDirRealPath: path.resolve(lockfileDir, projectId),
})
if (!resolveResult) {
targetSpecifiers[name] = targetDependencies[name] = spec
continue
}
targetSpecifiers[name] = targetDependencies[name] =
resolveResult.resolvedPath === deployedProjectRealPath ? 'link:.' : createFileUrlDepPath(resolveResult, allProjects)
}
}
const result: DeployFiles = {
lockfile: {
...lockfile,
overrides: undefined, // the effects of package overrides should already be part of the package snapshots
patchedDependencies: undefined,
packageExtensionsChecksum: undefined, // the effects of the package extensions should already be part of the package snapshots
pnpmfileChecksum: undefined, // the effects of the pnpmfile should already be part of the package snapshots
importers: {
['.' as ProjectId]: targetSnapshot,
},
packages: targetPackageSnapshots,
},
manifest: {
...pick(INHERITED_MANIFEST_KEYS, manifest),
dependencies: targetSnapshot.dependencies,
devDependencies: targetSnapshot.devDependencies,
optionalDependencies: targetSnapshot.optionalDependencies,
pnpm: {
...manifest.pnpm,
overrides: undefined, // the effects of package overrides should already be part of the package snapshots
patchedDependencies: undefined,
packageExtensions: undefined, // the effects of the package extensions should already be part of the package snapshots
},
},
}
if (lockfile.patchedDependencies) {
result.lockfile.patchedDependencies = {}
result.manifest.pnpm!.patchedDependencies = {}
for (const name in lockfile.patchedDependencies) {
const patchInfo = lockfile.patchedDependencies[name]
const resolvedPath = path.resolve(rootProjectManifestDir, patchInfo.path)
const relativePath = normalizePath(path.relative(deployDir, resolvedPath))
result.manifest.pnpm!.patchedDependencies[name] = relativePath
result.lockfile.patchedDependencies[name] = {
hash: patchInfo.hash,
path: relativePath,
}
}
}
return result
}
interface ConvertOptions {
allProjects: CreateDeployFilesOptions['allProjects']
deployDir: string
deployedProjectRealPath: string
projectRootDirRealPath: string
lockfileDir: string
}
function convertPackageSnapshot (inputSnapshot: PackageSnapshot, opts: ConvertOptions): PackageSnapshot {
const inputResolution = inputSnapshot.resolution
let outputResolution: LockfileResolution
if ('integrity' in inputResolution) {
outputResolution = inputResolution
} else if ('tarball' in inputResolution) {
outputResolution = { ...inputResolution }
if (inputResolution.tarball.startsWith('file:')) {
const inputPath = inputResolution.tarball.slice('file:'.length)
const resolvedPath = path.resolve(opts.lockfileDir, inputPath)
const outputPath = normalizePath(path.relative(opts.deployDir, resolvedPath))
outputResolution.tarball = `file:${outputPath}`
if (inputResolution.path) outputResolution.path = outputPath
}
} else if (inputResolution.type === 'directory') {
const resolvedPath = path.resolve(opts.lockfileDir, inputResolution.directory)
const directory = normalizePath(path.relative(opts.deployDir, resolvedPath))
outputResolution = { ...inputResolution, directory }
} else if (inputResolution.type === 'git') {
outputResolution = inputResolution
} else {
const resolution: never = inputResolution // `never` is the type guard to force fixing this code when adding new type of resolution
throw new Error(`Unknown resolution type: ${JSON.stringify(resolution)}`)
}
return {
...inputSnapshot,
resolution: outputResolution,
dependencies: convertResolvedDependencies(inputSnapshot.dependencies, opts),
optionalDependencies: convertResolvedDependencies(inputSnapshot.optionalDependencies, opts),
}
}
function convertProjectSnapshotToPackageSnapshot (projectSnapshot: ProjectSnapshot, opts: ConvertOptions): PackageSnapshot {
const resolution: DirectoryResolution = {
type: 'directory',
directory: normalizePath(path.relative(opts.deployDir, opts.projectRootDirRealPath)),
}
const dependencies = convertResolvedDependencies(projectSnapshot.dependencies, opts)
const optionalDependencies = convertResolvedDependencies(projectSnapshot.optionalDependencies, opts)
return {
dependencies,
optionalDependencies,
resolution,
}
}
function convertResolvedDependencies (
input: ResolvedDependencies | undefined,
opts: Pick<ConvertOptions, 'allProjects' | 'deployedProjectRealPath' | 'lockfileDir' | 'projectRootDirRealPath'>
): ResolvedDependencies | undefined {
if (!input) return undefined
const output: ResolvedDependencies = {}
for (const key in input) {
const spec = input[key]
const resolveResult = resolveLinkOrFile(spec, opts)
if (!resolveResult) {
output[key] = spec
continue
}
if (resolveResult.resolvedPath === opts.deployedProjectRealPath) {
output[key] = 'link:.' // the path is relative to the lockfile dir, which means '.' would reference the deploy dir
continue
}
output[key] = createFileUrlDepPath(resolveResult, opts.allProjects)
}
return output
}
interface ResolveLinkOrFileResult {
scheme: 'link:' | 'file:'
resolvedPath: string
suffix?: `(${string})`
}
function resolveLinkOrFile (spec: string, opts: Pick<ConvertOptions, 'lockfileDir' | 'projectRootDirRealPath'>): ResolveLinkOrFileResult | undefined {
// try parsing `spec` as `spec(peers)`
const hasPeers = /^(?<spec>[^()]+)(?<peers>\(.+\))$/.exec(spec)
if (hasPeers) {
const result = resolveLinkOrFile(hasPeers.groups!.spec, opts)
if (!result) return undefined
if (result.suffix) {
throw new Error(`Something goes wrong, suffix is not undefined: ${result.suffix}`)
}
result.suffix = hasPeers.groups!.peers as `(${string})`
return result
}
// try parsing `spec` as either @scope/name@pref or name@pref
const renamed = /^@(?<scope>[^@]+)\/(?<name>[^@]+)@(?<pref>.+)$/.exec(spec) ?? /^(?<name>[^@]+)@(?<pref>.+)$/.exec(spec)
if (renamed) return resolveLinkOrFile(renamed.groups!.pref, opts)
const { lockfileDir, projectRootDirRealPath } = opts
if (spec.startsWith('link:')) {
const targetPath = spec.slice('link:'.length)
return {
scheme: 'link:',
resolvedPath: path.resolve(projectRootDirRealPath, targetPath),
}
}
if (spec.startsWith('file:')) {
const targetPath = spec.slice('file:'.length)
return {
scheme: 'file:',
resolvedPath: path.resolve(lockfileDir, targetPath),
}
}
return undefined
}
function createFileUrlDepPath (
{ resolvedPath, suffix }: Pick<ResolveLinkOrFileResult, 'resolvedPath' | 'suffix'>,
allProjects: CreateDeployFilesOptions['allProjects']
): DepPath {
const depFileUrl = url.pathToFileURL(resolvedPath).toString()
const project = allProjects.find(project => project.rootDirRealPath === resolvedPath)
const name = project?.manifest.name ?? path.basename(resolvedPath)
return `${name}@${depFileUrl}${suffix ?? ''}` as DepPath
}

View File

@@ -1,26 +1,45 @@
import fs from 'fs'
import path from 'path'
import pick from 'ramda/src/pick'
import { docsUrl } from '@pnpm/cli-utils'
import { type Config, types as configTypes } from '@pnpm/config'
import { fetchFromDir } from '@pnpm/directory-fetcher'
import { createIndexedPkgImporter } from '@pnpm/fs.indexed-pkg-importer'
import { isEmptyDirOrNothing } from '@pnpm/fs.is-empty-dir-or-nothing'
import { install } from '@pnpm/plugin-commands-installation'
import { FILTERING } from '@pnpm/common-cli-options-help'
import { PnpmError } from '@pnpm/error'
import { readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile.fs'
import rimraf from '@zkochan/rimraf'
import renderHelp from 'render-help'
import { deployHook } from './deployHook'
import { logger } from '@pnpm/logger'
import { logger, globalWarn } from '@pnpm/logger'
import { type Project, type ProjectId } from '@pnpm/types'
import normalizePath from 'normalize-path'
import { createDeployFiles } from './createDeployFiles'
import { deployCatalogHook } from './deployCatalogHook'
export const shorthands = install.shorthands
const FORCE_LEGACY_DEPLOY = 'force-legacy-deploy' satisfies keyof typeof configTypes
export const shorthands = {
...install.shorthands,
legacy: [`--config.${FORCE_LEGACY_DEPLOY}=true`],
}
const DEPLOY_OWN_OPTIONS = pick([FORCE_LEGACY_DEPLOY], configTypes)
export function rcOptionsTypes (): Record<string, unknown> {
return install.rcOptionsTypes()
return {
...install.rcOptionsTypes(),
...DEPLOY_OWN_OPTIONS,
}
}
export function cliOptionsTypes (): Record<string, unknown> {
return install.cliOptionsTypes()
return {
...install.cliOptionsTypes(),
...DEPLOY_OWN_OPTIONS,
}
}
export const commandNames = ['deploy']
@@ -48,6 +67,10 @@ export function help (): string {
description: '`optionalDependencies` are not installed',
name: '--no-optional',
},
{
description: 'Force legacy deploy implementation',
name: '--legacy',
},
],
},
FILTERING,
@@ -55,27 +78,28 @@ export function help (): string {
})
}
export async function handler (
opts: Omit<install.InstallCommandOptions, 'useLockfile'>,
params: string[]
): Promise<void> {
export type DeployOptions =
& Omit<install.InstallCommandOptions, 'useLockfile'>
& Pick<Config, 'forceLegacyDeploy'>
export async function handler (opts: DeployOptions, params: string[]): Promise<void> {
if (!opts.workspaceDir) {
throw new PnpmError('CANNOT_DEPLOY', 'A deploy is only possible from inside a workspace')
}
if (!opts.injectWorkspacePackages) {
throw new PnpmError('DEPLOY_NONINJECTED_WORKSPACE', 'We only support deploy from workspaces that use the inject-workspace-packages=true setting')
}
const selectedDirs = Object.keys(opts.selectedProjectsGraph ?? {})
if (selectedDirs.length === 0) {
const selectedProjects = Object.values(opts.selectedProjectsGraph ?? {})
if (selectedProjects.length === 0) {
throw new PnpmError('NOTHING_TO_DEPLOY', 'No project was selected for deployment')
}
if (selectedDirs.length > 1) {
if (selectedProjects.length > 1) {
throw new PnpmError('CANNOT_DEPLOY_MANY', 'Cannot deploy more than 1 project')
}
if (params.length !== 1) {
throw new PnpmError('INVALID_DEPLOY_TARGET', 'This command requires one parameter')
}
const deployedDir = selectedDirs[0]
const selectedProject = selectedProjects[0].package
const deployDirParam = params[0]
const deployDir = path.isAbsolute(deployDirParam) ? deployDirParam : path.join(opts.dir, deployDirParam)
@@ -90,10 +114,22 @@ export async function handler (
await rimraf(deployDir)
await fs.promises.mkdir(deployDir, { recursive: true })
const includeOnlyPackageFiles = !opts.deployAllFiles
await copyProject(deployedDir, deployDir, { includeOnlyPackageFiles })
const deployedProject = opts.allProjects?.find(({ rootDir }) => rootDir === deployedDir)
await copyProject(selectedProject.rootDir, deployDir, { includeOnlyPackageFiles })
if (opts.sharedWorkspaceLockfile) {
const warning = opts.forceLegacyDeploy
? 'Shared workspace lockfile detected but configuration forces legacy deploy implementation.'
: await deployFromSharedLockfile(opts, selectedProject, deployDir)
if (warning) {
globalWarn(warning)
} else {
return
}
}
const deployedProject = opts.allProjects?.find(({ rootDir }) => rootDir === selectedProject.rootDir)
if (deployedProject) {
deployedProject.modulesDir = path.relative(deployedDir, path.join(deployDir, 'node_modules'))
deployedProject.modulesDir = path.relative(selectedProject.rootDir, path.join(deployDir, 'node_modules'))
}
await install.handler({
...opts,
@@ -134,3 +170,85 @@ async function copyProject (src: string, dest: string, opts: { includeOnlyPackag
const importPkg = createIndexedPkgImporter('clone-or-copy')
importPkg(dest, { filesMap: filesIndex, force: true, resolvedFrom: 'local-dir' })
}
async function deployFromSharedLockfile (
opts: DeployOptions,
selectedProject: Pick<Project, 'rootDir'> & {
manifest: Pick<Project['manifest'], 'name' | 'version'>
},
deployDir: string
): Promise<string | undefined> {
const {
allProjects,
lockfileDir,
rootProjectManifestDir,
workspaceDir,
} = opts
// The following errors should not be possible. It is a programmer error if they are reached.
if (!allProjects) throw new Error('opts.allProjects is undefined.')
if (!lockfileDir) throw new Error('opts.lockfileDir is undefined.')
if (!workspaceDir) throw new Error('opts.workspaceDir is undefined.')
const lockfile = await readWantedLockfile(lockfileDir, { ignoreIncompatible: false })
if (!lockfile) {
return 'Shared lockfile not found. Falling back to installing without a lockfile.'
}
const projectId = normalizePath(path.relative(workspaceDir, selectedProject.rootDir)) as ProjectId
const deployFiles = createDeployFiles({
allProjects,
deployDir,
lockfile,
lockfileDir,
manifest: selectedProject.manifest,
projectId,
rootProjectManifestDir,
})
await Promise.all([
fs.promises.writeFile(
path.join(deployDir, 'package.json'),
JSON.stringify(deployFiles.manifest, undefined, 2) + '\n'
),
writeWantedLockfile(deployDir, deployFiles.lockfile),
])
try {
await install.handler({
...opts,
allProjects: undefined,
allProjectsGraph: undefined,
selectedProjectsGraph: undefined,
rootProjectManifest: deployFiles.manifest,
rootProjectManifestDir: deployDir,
dir: deployDir,
lockfileDir: deployDir,
workspaceDir: undefined,
virtualStoreDir: undefined,
modulesDir: undefined,
confirmModulesPurge: false,
frozenLockfile: true,
hooks: {
...opts.hooks,
readPackage: [
...(opts.hooks?.readPackage ?? []),
deployHook,
deployCatalogHook.bind(null, opts.catalogs ?? {}),
],
calculatePnpmfileChecksum: undefined, // the effects of the pnpmfile should already be part of the package snapshots
},
rawLocalConfig: {
...opts.rawLocalConfig,
'frozen-lockfile': true,
},
})
} catch (error) {
globalWarn('Deployment with a shared lockfile has failed. If this is a bug, please report it at <https://github.com/pnpm/pnpm/issues>.')
globalWarn(`As a workaround, you may add ${FORCE_LEGACY_DEPLOY}=true to .npmrc.`)
throw error
}
return undefined
}

View File

@@ -3,11 +3,20 @@ import path from 'path'
import { deploy } from '@pnpm/plugin-commands-deploy'
import { assertProject } from '@pnpm/assert-project'
import { preparePackages } from '@pnpm/prepare'
import { logger } from '@pnpm/logger'
import { logger, globalWarn } from '@pnpm/logger'
import { filterPackagesFromDir } from '@pnpm/workspace.filter-packages-from-dir'
import { DEFAULT_OPTS } from './utils'
test('deploy', async () => {
beforeEach(async () => {
const logger = await import('@pnpm/logger')
jest.spyOn(logger, 'globalWarn')
})
afterEach(() => {
jest.restoreAllMocks()
})
test('deploy without existing lockfile', async () => {
preparePackages([
{
name: 'project-1',
@@ -62,6 +71,8 @@ test('deploy', async () => {
workspaceDir: process.cwd(),
}, ['deploy'])
expect(globalWarn).toHaveBeenCalledWith('Shared lockfile not found. Falling back to installing without a lockfile.')
const project = assertProject(path.resolve('deploy'))
project.has('project-2')
project.has('is-positive')

View File

@@ -0,0 +1,19 @@
diff --git a/PATCH.txt b/PATCH.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e6d52b1e6644678337868f7b5d6cc6dc0d040891
--- /dev/null
+++ b/PATCH.txt
@@ -0,0 +1 @@
+added by pnpm patch-commit
diff --git a/package.json b/package.json
index 5feb15ba194c74ad48a2ee15abec9887ec1f9e83..27e24feff1bbfc20b3735c23b332bfdc16803362 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"test": "xo && ava"
},
"files": [
+ "PATCH.txt",
"index.js"
],
"keywords": [

View File

@@ -0,0 +1,962 @@
import fs from 'fs'
import path from 'path'
import url from 'url'
import { deploy } from '@pnpm/plugin-commands-deploy'
import { install } from '@pnpm/plugin-commands-installation'
import { assertProject } from '@pnpm/assert-project'
import { preparePackages } from '@pnpm/prepare'
import { type PatchFile, type LockfileFile, type LockfilePackageSnapshot } from '@pnpm/lockfile.types'
import { globalWarn } from '@pnpm/logger'
import { filterPackagesFromDir } from '@pnpm/workspace.filter-packages-from-dir'
import { fixtures } from '@pnpm/test-fixtures'
import { type ProjectManifest } from '@pnpm/types'
import { DEFAULT_OPTS } from './utils'
const f = fixtures(__dirname)
const resolvePathAsUrl = (...paths: string[]): string => url.pathToFileURL(path.resolve(...paths)).toString()
beforeEach(async () => {
const logger = await import('@pnpm/logger')
jest.spyOn(logger, 'globalWarn')
})
afterEach(() => {
jest.restoreAllMocks()
})
function readPackageJson (manifestDir: string): unknown {
const manifestPath = path.resolve(manifestDir, 'package.json')
const manifestText = fs.readFileSync(manifestPath, 'utf-8')
return JSON.parse(manifestText)
}
test('deploy with a shared lockfile after full install', async () => {
const projectNames = ['project-1', 'project-2', 'project-3', 'project-4', 'project-5'] as const
const preparedManifests: Record<typeof projectNames[number], ProjectManifest> = {
'project-1': {
name: 'project-1',
version: '1.0.0',
files: ['index.js'],
dependencies: {
'project-2': 'workspace:*',
'is-positive': '1.0.0',
},
devDependencies: {
'project-3': 'workspace:*',
'is-negative': '1.0.0',
},
},
'project-2': {
name: 'project-2',
version: '2.0.0',
files: ['index.js'],
dependencies: {
'project-3': 'workspace:*',
'project-4': 'workspace:*',
'renamed-project-2': 'workspace:project-2@*',
'is-odd': '1.0.0',
},
},
'project-3': {
name: 'project-3',
version: '2.0.0',
files: ['index.js'],
dependencies: {
'project-3': 'workspace:*',
'project-5': 'workspace:*',
'is-odd': '1.0.0',
},
},
'project-4': {
name: 'project-4',
version: '0.0.0',
},
'project-5': {
name: 'project-5',
version: '0.0.0',
},
}
preparePackages(projectNames.map(name => preparedManifests[name]))
for (const name of projectNames) {
fs.writeFileSync(`${name}/test.js`, '', 'utf8')
fs.writeFileSync(`${name}/index.js`, '', 'utf8')
}
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(process.cwd(), [{ namePattern: 'project-1' }])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
selectedProjectsGraph: allProjectsGraph,
dir: process.cwd(),
recursive: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
})
expect(fs.existsSync('pnpm-lock.yaml')).toBeTruthy()
const expectedDeployManifest: ProjectManifest = {
name: 'project-1',
version: '1.0.0',
dependencies: {
'project-2': expect.stringMatching(/^project-2@file:/),
'is-positive': '1.0.0',
},
devDependencies: {
'project-3': expect.stringMatching(/^project-3@file:/),
'is-negative': '1.0.0',
},
optionalDependencies: {},
pnpm: {},
}
// deploy prod only
{
fs.rmSync('deploy', { recursive: true, force: true })
await deploy.handler({
...DEFAULT_OPTS,
allProjects,
dir: process.cwd(),
dev: false,
production: true,
recursive: true,
selectedProjectsGraph,
sharedWorkspaceLockfile: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
}, ['deploy'])
const project = assertProject(path.resolve('deploy'))
project.has('project-2')
project.has('is-positive')
project.hasNot('project-3')
project.hasNot('is-negative')
project.hasNot('project-4')
project.hasNot('project-5')
expect(readPackageJson('deploy')).toStrictEqual(expectedDeployManifest)
expect(fs.existsSync('deploy/pnpm-lock.yaml'))
expect(fs.existsSync('deploy/index.js')).toBeTruthy()
expect(fs.existsSync('deploy/test.js')).toBeFalsy()
expect(fs.existsSync('deploy/node_modules/.modules.yaml')).toBeTruthy()
const project2Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.startsWith('project-2@'))
expect(project2Name).toBeDefined()
expect(fs.realpathSync('deploy/node_modules/project-2')).toBe(path.resolve(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-2`))
expect(fs.existsSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-2/index.js`)).toBeTruthy()
expect(fs.existsSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-2/test.js`)).toBeFalsy()
expect(fs.readdirSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules`).sort()).toStrictEqual([
'is-odd',
'project-2',
'project-3',
'project-4',
'renamed-project-2',
])
expect(readPackageJson(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-2`)).toStrictEqual(preparedManifests['project-2'])
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/renamed-project-2`)).toBe(
path.resolve(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-2`)
)
const project3Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.startsWith('project-3@'))
expect(project3Name).toBeDefined()
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-3`)).toBe(
path.resolve(`deploy/node_modules/.pnpm/${project3Name}/node_modules/project-3`)
)
expect(fs.readdirSync(`deploy/node_modules/.pnpm/${project3Name}/node_modules`).sort()).toStrictEqual([
'is-odd',
'project-3',
'project-5',
])
expect(readPackageJson(`deploy/node_modules/.pnpm/${project3Name}/node_modules/project-3`)).toStrictEqual(preparedManifests['project-3'])
const project4Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.startsWith('project-4@'))
expect(project4Name).toBeDefined()
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-4`)).toBe(
path.resolve(`deploy/node_modules/.pnpm/${project4Name}/node_modules/project-4`)
)
expect(readPackageJson(`deploy/node_modules/.pnpm/${project4Name}/node_modules/project-4`)).toStrictEqual(preparedManifests['project-4'])
const project5Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.startsWith('project-5@'))
expect(project5Name).toBeDefined()
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project3Name}/node_modules/project-5`)).toBe(
path.resolve(`deploy/node_modules/.pnpm/${project5Name}/node_modules/project-5`)
)
expect(readPackageJson(`deploy/node_modules/.pnpm/${project5Name}/node_modules/project-5`)).toStrictEqual(preparedManifests['project-5'])
expect(globalWarn).not.toHaveBeenCalledWith(expect.stringContaining('Falling back to installing without a lockfile'))
}
// deploy all
{
fs.rmSync('deploy', { recursive: true, force: true })
await deploy.handler({
...DEFAULT_OPTS,
allProjects,
dir: process.cwd(),
recursive: true,
selectedProjectsGraph,
sharedWorkspaceLockfile: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
}, ['deploy'])
const project = assertProject(path.resolve('deploy'))
project.has('project-2')
project.has('is-positive')
project.has('project-3')
project.has('is-negative')
project.hasNot('project-4')
project.hasNot('project-5')
expect(readPackageJson('deploy')).toStrictEqual(expectedDeployManifest)
expect(fs.existsSync('deploy/pnpm-lock.yaml'))
expect(fs.existsSync('deploy/index.js')).toBeTruthy()
expect(fs.existsSync('deploy/test.js')).toBeFalsy()
expect(fs.existsSync('deploy/node_modules/.modules.yaml')).toBeTruthy()
const project2Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.startsWith('project-2@'))
expect(project2Name).toBeDefined()
expect(fs.realpathSync('deploy/node_modules/project-2')).toBe(path.resolve(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-2`))
expect(fs.existsSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-2/index.js`)).toBeTruthy()
expect(fs.existsSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-2/test.js`)).toBeFalsy()
expect(fs.readdirSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules`).sort()).toStrictEqual([
'is-odd',
'project-2',
'project-3',
'project-4',
'renamed-project-2',
])
const project3Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.startsWith('project-3@'))
expect(project3Name).toBeDefined()
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-3`)).toBe(
path.resolve(`deploy/node_modules/.pnpm/${project3Name}/node_modules/project-3`)
)
expect(project3Name).toBeDefined()
expect(fs.existsSync(`deploy/node_modules/.pnpm/${project3Name}/node_modules/project-3/index.js`)).toBeTruthy()
expect(fs.existsSync(`deploy/node_modules/.pnpm/${project3Name}/node_modules/project-3/test.js`)).toBeFalsy()
expect(fs.readdirSync(`deploy/node_modules/.pnpm/${project3Name}/node_modules`).sort()).toStrictEqual([
'is-odd',
'project-3',
'project-5',
])
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project3Name}/node_modules/project-3`)).toContain(project3Name)
const project4Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.startsWith('project-4@'))
expect(project4Name).toBeDefined()
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project2Name}/node_modules/project-4`)).toBe(
path.resolve(`deploy/node_modules/.pnpm/${project4Name}/node_modules/project-4`)
)
const project5Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.startsWith('project-5@'))
expect(project5Name).toBeDefined()
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project3Name}/node_modules/project-5`)).toBe(
path.resolve(`deploy/node_modules/.pnpm/${project5Name}/node_modules/project-5`)
)
expect(globalWarn).not.toHaveBeenCalledWith(expect.stringContaining('Falling back to installing without a lockfile'))
}
})
test('deploy with a shared lockfile and --prod filter should not fail even if dev workspace package does not exist (#8778)', async () => {
preparePackages([
{
name: 'prod-0',
version: '0.0.0',
private: true,
dependencies: {
'prod-1': 'workspace:*',
},
devDependencies: {
'dev-0': 'workspace:*',
'is-negative': '1.0.0',
},
},
{
name: 'prod-1',
version: '0.0.0',
private: true,
dependencies: {
'is-positive': '1.0.0',
},
devDependencies: {
'dev-1': 'workspace:*',
'is-negative': '1.0.0',
},
},
{
name: 'dev-0',
version: '0.0.0',
private: true,
dependencies: {
'is-negative': '1.0.0',
},
},
{
name: 'dev-1',
version: '0.0.0',
private: true,
},
])
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(process.cwd(), [{ namePattern: 'prod-0' }])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
selectedProjectsGraph: allProjectsGraph,
dir: process.cwd(),
recursive: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
})
expect(fs.existsSync('pnpm-lock.yaml')).toBeTruthy()
fs.rmSync('dev-0', { recursive: true })
fs.rmSync('dev-1', { recursive: true })
await deploy.handler({
...DEFAULT_OPTS,
allProjects,
dir: process.cwd(),
recursive: true,
production: true,
dev: false,
selectedProjectsGraph,
sharedWorkspaceLockfile: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
}, ['deploy'])
const project = assertProject(path.resolve('deploy'))
project.has('prod-1')
project.hasNot('dev-0')
project.hasNot('dev-1')
const lockfile = project.readLockfile()
expect(lockfile.importers).toStrictEqual({
'.': {
dependencies: {
'prod-1': {
version: expect.stringMatching(/^prod-1@file:/),
specifier: expect.stringMatching(/^prod-1@file:/),
},
},
devDependencies: {
'dev-0': {
version: expect.stringMatching(/^dev-0@file:/),
specifier: expect.stringMatching(/^dev-0@file:/),
},
'is-negative': {
version: '1.0.0',
specifier: '1.0.0',
},
},
},
} as LockfileFile['importers'])
const manifest = readPackageJson('deploy') as ProjectManifest
expect(manifest).toStrictEqual({
name: 'prod-0',
version: '0.0.0',
private: true,
dependencies: {
'prod-1': expect.stringMatching(/^prod-1@file:/),
},
devDependencies: {
'dev-0': expect.stringMatching(/^dev-0@file:/),
'is-negative': '1.0.0',
},
optionalDependencies: {},
pnpm: {},
} as ProjectManifest)
const prod1Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.includes('prod-1@'))
expect(prod1Name).toBeDefined()
expect(fs.readdirSync(`deploy/node_modules/.pnpm/${prod1Name}/node_modules`).sort()).toStrictEqual(['is-positive', 'prod-1'])
expect(fs.realpathSync('deploy/node_modules/prod-1')).toBe(path.resolve(`deploy/node_modules/.pnpm/${prod1Name}/node_modules/prod-1`))
})
test('deploy with a shared lockfile should correctly handle workspace dependencies that depend on the deployed project', async () => {
preparePackages([
{
name: 'project-0',
version: '0.0.0',
private: true,
dependencies: {
'project-1': 'workspace:*',
},
},
{
name: 'project-1',
version: '0.0.0',
private: true,
dependencies: {
'project-0': 'workspace:*',
},
},
])
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(process.cwd(), [{ namePattern: 'project-0' }])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
selectedProjectsGraph: allProjectsGraph,
dir: process.cwd(),
recursive: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
})
expect(fs.existsSync('pnpm-lock.yaml')).toBeTruthy()
await deploy.handler({
...DEFAULT_OPTS,
allProjects,
dir: process.cwd(),
recursive: true,
selectedProjectsGraph,
sharedWorkspaceLockfile: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
}, ['deploy'])
const project = assertProject(path.resolve('deploy'))
project.has('project-1')
const lockfile = project.readLockfile()
expect(lockfile.importers).toStrictEqual({
'.': {
dependencies: {
'project-1': {
version: expect.stringMatching(/^project-1@file:/),
specifier: expect.stringMatching(/^project-1@file:/),
},
},
},
} as LockfileFile['importers'])
const manifest = readPackageJson('deploy') as ProjectManifest
expect(manifest).toStrictEqual({
name: 'project-0',
version: '0.0.0',
private: true,
dependencies: {
'project-1': expect.stringMatching(/^project-1@file:/),
},
devDependencies: {},
optionalDependencies: {},
pnpm: {},
} as ProjectManifest)
const project1Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.includes('project-1@'))
expect(project1Name).toBeDefined()
expect(fs.readdirSync(`deploy/node_modules/.pnpm/${project1Name}/node_modules`).sort()).toStrictEqual(['project-0', 'project-1'])
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project1Name}/node_modules/project-0`)).toBe(path.resolve('deploy'))
expect(fs.realpathSync('deploy/node_modules/project-1')).toBe(path.resolve(`deploy/node_modules/.pnpm/${project1Name}/node_modules/project-1`))
})
test('deploy with a shared lockfile should correctly handle package that depends on itself', async () => {
preparePackages([
{
name: 'project-0',
version: '0.0.0',
private: true,
dependencies: {
'project-0': 'workspace:*',
'renamed-workspace': 'workspace:project-0@*',
'renamed-linked': 'link:.',
},
},
])
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(process.cwd(), [{ namePattern: 'project-0' }])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
selectedProjectsGraph: allProjectsGraph,
dir: process.cwd(),
recursive: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
})
expect(fs.existsSync('pnpm-lock.yaml')).toBeTruthy()
await deploy.handler({
...DEFAULT_OPTS,
allProjects,
dir: process.cwd(),
recursive: true,
selectedProjectsGraph,
sharedWorkspaceLockfile: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
}, ['deploy'])
const project = assertProject(path.resolve('deploy'))
project.has('project-0')
project.has('renamed-workspace')
project.has('renamed-linked')
const lockfile = project.readLockfile()
expect(lockfile.importers).toStrictEqual({
'.': {
dependencies: {
'project-0': {
version: 'link:.',
specifier: 'link:.',
},
'renamed-workspace': {
version: 'link:.',
specifier: 'link:.',
},
'renamed-linked': {
version: 'link:.',
specifier: 'link:.',
},
},
},
} as LockfileFile['importers'])
const manifest = readPackageJson('deploy') as ProjectManifest
expect(manifest).toStrictEqual({
name: 'project-0',
version: '0.0.0',
private: true,
dependencies: {
'project-0': 'link:.',
'renamed-workspace': 'link:.',
'renamed-linked': 'link:.',
},
devDependencies: {},
optionalDependencies: {},
pnpm: {},
} as ProjectManifest)
expect(fs.realpathSync('deploy/node_modules/project-0')).toBe(path.resolve('deploy'))
expect(fs.realpathSync('deploy/node_modules/renamed-workspace')).toBe(path.resolve('deploy'))
expect(fs.realpathSync('deploy/node_modules/renamed-linked')).toBe(path.resolve('deploy'))
})
test('deploy with a shared lockfile should correctly handle packageExtensions', async () => {
const preparedManifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
version: '0.0.0',
private: true,
pnpm: {
packageExtensions: {
'is-positive': {
dependencies: {
'is-odd': '1.0.0',
'link-to-project-0': 'link:project-0',
'link-to-project-1': 'link:project-1',
'project-0': 'workspace:*',
'project-1': 'workspace:*',
},
},
},
},
},
'project-0': {
name: 'project-0',
version: '0.0.0',
dependencies: {
'project-1': 'workspace:*',
},
},
'project-1': {
name: 'project-1',
version: '0.0.0',
dependencies: {
'is-positive': '1.0.0',
},
},
}
preparePackages([
{
location: '.',
package: preparedManifests.root,
},
preparedManifests['project-0'],
preparedManifests['project-1'],
])
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(process.cwd(), [{ namePattern: 'project-0' }])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
selectedProjectsGraph: allProjectsGraph,
dir: process.cwd(),
recursive: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
})
expect(fs.existsSync('pnpm-lock.yaml')).toBeTruthy()
await deploy.handler({
...DEFAULT_OPTS,
allProjects,
dir: process.cwd(),
recursive: true,
selectedProjectsGraph,
sharedWorkspaceLockfile: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
}, ['deploy'])
const project = assertProject(path.resolve('deploy'))
project.has('project-1')
const lockfile = project.readLockfile()
expect(lockfile).toHaveProperty(['snapshots', 'is-positive@1.0.0'], {
dependencies: {
'is-odd': '1.0.0',
'link-to-project-0': 'link:.',
'link-to-project-1': expect.stringMatching(/^project-1@file:/),
'project-0': 'link:.',
'project-1': expect.stringMatching(/^project-1@file:/),
},
} as LockfilePackageSnapshot)
const manifest = readPackageJson('deploy') as ProjectManifest
expect(manifest).toStrictEqual({
name: 'project-0',
version: '0.0.0',
dependencies: {
'project-1': expect.stringMatching(/^project-1@file:/),
},
devDependencies: {},
optionalDependencies: {},
pnpm: {},
} as ProjectManifest)
const project1Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.includes('project-1@'))
expect(project1Name).toBeDefined()
expect(fs.realpathSync('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/is-odd'))
.toBe(path.resolve('deploy/node_modules/.pnpm/is-odd@1.0.0/node_modules/is-odd'))
expect(fs.realpathSync('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/link-to-project-0')).toBe(path.resolve('deploy'))
expect(fs.realpathSync('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/link-to-project-1'))
.toBe(path.resolve(`deploy/node_modules/.pnpm/${project1Name}/node_modules/project-1`))
expect(fs.realpathSync('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/project-0')).toBe(path.resolve('deploy'))
expect(fs.realpathSync('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/project-1'))
.toBe(path.resolve(`deploy/node_modules/.pnpm/${project1Name}/node_modules/project-1`))
expect(readPackageJson('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/link-to-project-0')).toStrictEqual(manifest)
expect(readPackageJson('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/link-to-project-1')).toStrictEqual(preparedManifests['project-1'])
expect(readPackageJson('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/project-0')).toStrictEqual(manifest)
expect(readPackageJson('deploy/node_modules/.pnpm/is-positive@1.0.0/node_modules/project-1')).toStrictEqual(preparedManifests['project-1'])
})
test('deploy with a shared lockfile should correctly handle patchedDependencies', async () => {
const preparedManifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
version: '0.0.0',
private: true,
pnpm: {
patchedDependencies: {
'is-positive': '__patches__/is-positive.patch',
},
},
},
'project-0': {
name: 'project-0',
version: '0.0.0',
dependencies: {
'project-1': 'workspace:*',
},
},
'project-1': {
name: 'project-1',
version: '0.0.0',
dependencies: {
'is-positive': '1.0.0',
},
},
}
preparePackages([
{
location: '.',
package: preparedManifests.root,
},
preparedManifests['project-0'],
preparedManifests['project-1'],
])
f.copy('is-positive.patch', '__patches__/is-positive.patch')
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(process.cwd(), [{ namePattern: 'project-0' }])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
selectedProjectsGraph: allProjectsGraph,
dir: process.cwd(),
recursive: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
})
expect(fs.existsSync('pnpm-lock.yaml')).toBeTruthy()
await deploy.handler({
...DEFAULT_OPTS,
allProjects,
dir: process.cwd(),
recursive: true,
selectedProjectsGraph,
sharedWorkspaceLockfile: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
}, ['deploy'])
const project = assertProject(path.resolve('deploy'))
project.has('project-1')
const lockfile = project.readLockfile()
expect(lockfile.patchedDependencies).toStrictEqual({
'is-positive': {
hash: expect.any(String),
path: '../__patches__/is-positive.patch',
},
} as Record<string, PatchFile>)
const patchFile = lockfile.patchedDependencies['is-positive']
const manifest = readPackageJson('deploy') as ProjectManifest
expect(manifest).toStrictEqual({
name: 'project-0',
version: '0.0.0',
dependencies: {
'project-1': expect.stringMatching(/^project-1@file:/),
},
devDependencies: {},
optionalDependencies: {},
pnpm: {
patchedDependencies: {
'is-positive': '../__patches__/is-positive.patch',
},
},
} as ProjectManifest)
const project1Name = fs.readdirSync('deploy/node_modules/.pnpm').find(name => name.includes('project-1@'))
expect(project1Name).toBeDefined()
if (process.platform !== 'win32') {
expect(fs.realpathSync(`deploy/node_modules/.pnpm/${project1Name}/node_modules/is-positive`)).toBe(
path.resolve(`deploy/node_modules/.pnpm/is-positive@1.0.0_patch_hash=${patchFile.hash}/node_modules/is-positive`)
)
}
expect(
fs.readFileSync(`deploy/node_modules/.pnpm/${project1Name}/node_modules/is-positive/PATCH.txt`, 'utf-8')
.trim()
).toBe('added by pnpm patch-commit')
})
test('deploy with a shared lockfile that has peer dependencies suffix in workspace package dependency paths', async () => {
const preparedManifests: Record<string, ProjectManifest> = {
'project-0': {
name: 'project-0',
version: '0.0.0',
dependencies: {
'project-1': 'workspace:*',
},
peerDependencies: {
'project-1': '*',
'project-2': '*',
},
},
'project-1': {
name: 'project-1',
version: '0.0.0',
dependencies: {
'is-positive': '1.0.0',
'project-2': 'workspace:*',
},
peerDependencies: {
'is-negative': '>=1.0.0',
'project-2': '*',
},
},
'project-2': {
name: 'project-2',
version: '0.0.0',
peerDependencies: {
'is-positive': '>=1.0.0',
},
},
}
preparePackages(['project-0', 'project-1', 'project-2'].map(name => ({
location: `packages/${name}`,
package: preparedManifests[name],
})))
const {
allProjects,
allProjectsGraph,
selectedProjectsGraph,
} = await filterPackagesFromDir(process.cwd(), [{ namePattern: 'project-0' }])
await install.handler({
...DEFAULT_OPTS,
allProjects,
allProjectsGraph,
selectedProjectsGraph: allProjectsGraph,
dedupeInjectedDeps: false,
dir: process.cwd(),
recursive: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
})
expect(assertProject('.').readLockfile()).toMatchObject({
importers: {
'packages/project-0': {
dependencies: {
'project-1': {
version: 'file:packages/project-1(is-negative@1.0.0)(project-2@file:packages/project-2(is-positive@1.0.0))',
},
'project-2': {
version: 'file:packages/project-2(is-positive@1.0.0)',
},
},
},
'packages/project-1': {
dependencies: {
'project-2': {
version: 'file:packages/project-2(is-positive@1.0.0)',
},
},
},
},
packages: {
'project-1@file:packages/project-1': {
resolution: {
type: 'directory',
directory: 'packages/project-1',
},
},
'project-2@file:packages/project-2': {
resolution: {
type: 'directory',
directory: 'packages/project-2',
},
},
},
snapshots: {
'project-1@file:packages/project-1(is-negative@1.0.0)(project-2@file:packages/project-2(is-positive@1.0.0))': {
dependencies: {
'project-2': 'file:packages/project-2(is-positive@1.0.0)',
},
},
'project-2@file:packages/project-2(is-positive@1.0.0)': {},
},
})
await deploy.handler({
...DEFAULT_OPTS,
allProjects,
dir: process.cwd(),
recursive: true,
selectedProjectsGraph,
sharedWorkspaceLockfile: true,
lockfileDir: process.cwd(),
workspaceDir: process.cwd(),
}, ['deploy'])
const project = assertProject(path.resolve('deploy'))
project.has('project-1')
project.has('project-2')
expect(project.readLockfile()).toMatchObject({
importers: {
'.': {
dependencies: {
'project-1': {
specifier: `project-1@${resolvePathAsUrl('packages/project-1')}(is-negative@1.0.0)(project-2@file:packages/project-2(is-positive@1.0.0))`,
version: `project-1@${resolvePathAsUrl('packages/project-1')}(is-negative@1.0.0)(project-2@file:packages/project-2(is-positive@1.0.0))`,
},
'project-2': {
specifier: `project-2@${resolvePathAsUrl('packages/project-2')}(is-positive@1.0.0)`,
version: `project-2@${resolvePathAsUrl('packages/project-2')}(is-positive@1.0.0)`,
},
},
},
},
packages: {
[`project-1@${resolvePathAsUrl('packages/project-1')}`]: {
resolution: {
type: 'directory',
directory: '../packages/project-1',
},
},
[`project-2@${resolvePathAsUrl('packages/project-2')}`]: {
resolution: {
type: 'directory',
directory: '../packages/project-2',
},
},
},
snapshots: {
[`project-1@${resolvePathAsUrl('packages/project-1')}(is-negative@1.0.0)(project-2@file:packages/project-2(is-positive@1.0.0))`]: {
dependencies: {
'project-2': `project-2@${resolvePathAsUrl('packages/project-2')}(is-positive@1.0.0)`,
},
},
[`project-2@${resolvePathAsUrl('packages/project-2')}(is-positive@1.0.0)`]: {},
},
})
expect(readPackageJson('deploy')).toStrictEqual({
name: 'project-0',
version: '0.0.0',
dependencies: {
'project-1': `project-1@${resolvePathAsUrl('packages/project-1')}(is-negative@1.0.0)(project-2@file:packages/project-2(is-positive@1.0.0))`,
'project-2': `project-2@${resolvePathAsUrl('packages/project-2')}(is-positive@1.0.0)`,
},
devDependencies: {},
optionalDependencies: {},
pnpm: {},
} as ProjectManifest)
expect(readPackageJson('deploy/node_modules/project-1')).toStrictEqual(preparedManifests['project-1'])
expect(readPackageJson('deploy/node_modules/project-2')).toStrictEqual(preparedManifests['project-2'])
const project1Names = fs.readdirSync('deploy/node_modules/.pnpm').filter(name => name.includes('project-1@'))
expect(project1Names).not.toStrictEqual([])
for (const name of project1Names) {
expect(readPackageJson(`deploy/node_modules/.pnpm/${name}/node_modules/project-1`)).toStrictEqual(preparedManifests['project-1'])
}
const project2Names = fs.readdirSync('deploy/node_modules/.pnpm').filter(name => name.includes('project-2@'))
expect(project2Names).not.toStrictEqual([])
for (const name of project2Names) {
expect(readPackageJson(`deploy/node_modules/.pnpm/${name}/node_modules/project-2`)).toStrictEqual(preparedManifests['project-2'])
}
})

View File

@@ -15,6 +15,9 @@
{
"path": "../../__utils__/prepare"
},
{
"path": "../../__utils__/test-fixtures"
},
{
"path": "../../catalogs/resolver"
},
@@ -27,6 +30,9 @@
{
"path": "../../cli/common-cli-options-help"
},
{
"path": "../../config/config"
},
{
"path": "../../fetching/directory-fetcher"
},
@@ -36,6 +42,9 @@
{
"path": "../../fs/is-empty-dir-or-nothing"
},
{
"path": "../../lockfile/fs"
},
{
"path": "../../lockfile/types"
},