feat(exec): check dependencies before running scripts (#8645)

* refactor: break a long line into multiple lines

* feat: cache that tracks workspace structures

* feat: handle hash collisions

* docs(changeset): packages-list-cache

* feat(packages-list-cache): store mtime

* fix(packages-list-cache): JSON5 and YAML manifests

* feat(packages-list-cache): add catalogs

* style: sort fields alphabetically

* fix: actually fix it

* lint: fix

* lint: fix

* test(packages-list-cache): test

* feat(exec): check deps before run scripts

Resolves https://github.com/pnpm/pnpm/issues/8585

* style: fix eslint

* feat: use a single lastValidatedTimestamp

* refactor: rearrange

* perf: don't do pointless comparisons

* perf: optimize non-workspace

* perf: optimize sharedWorkspaceLockfile=false

* perf: remove unnecessary fs reads

* refactor: statManifestFile

* perf: skip comparing manifest to lockfile by stats

* feat: add wantedLockfileDir to error message

* refactor: shorten a function name

* refactor: rename a function

* docs: improve wordings

* feat: export `linkedPackagesAreUpToDate`

* feat: make sure lockfile specs satisfy manifest (wip)

* docs: todo

* fix: projectId

* feat: skip install-related scripts

* fix: type errors

* refactor: use tagged union

* refactor: remove unnecessary type expression

* docs: todo

* feat: add linkedPackagesAreUpToDate (wip)

* refactor: rearrange fields

* refactor: remove a temporary variable

* feat: export `getWorkspacePackagesByDirectory`

* feat: make workspacePackages optional

* feat: complete `linkedPackagesAreUpToDate`

* docs: remove unapplicable todo

* docs: explain why check is skipped

* feat: load allProjects and try again

* refactor: remove unused dependencies

* refactor: remove commented-out code

* refactor: replace `else if` with `return`

* feat: use-case without workspace manifest

* perf: skip unnecessary work

* feat: add a guard

* fix: eslint

* refactor: move code to new package

* refactor: sort dependencies

* test: outline

* refactor: extract assertLockfilesEqual for testing

* test: skip failing tests for now

* fix: eslint

* test: assertLockfilesEqual

* refactor: extract statManifestFile for testing

* test: todo

* test: statManifestFile

* test: shouldRunCheck

* refactor: rename a test file

* test: add

* test: todo

* docs: remove a commented-out code

* test: create groups

* test: todo

* test: add

* test: platform agnostic

* test: remove unnecessary scripts

* test: use `assert.strictEqual` instead

* test: export bin locations

* test: nested `pnpm run`

* test: todo

* test: add `cwd` option to `execPnpmSync`

* test: add

* fix: recursive

* test: add

* test: fix package names

* fix: catalogs comparison

* test: add

* refactor: just use ramda filter

* test: add

* test: mutations

* fix: package.json

* fix: jest

* feat(packages-list): debug logs

* feat: add debug messages

* fix: eslint

* test: check debug messages in other case

* docs: add next step

* test: mtime updates without modification

* docs: correct test description

* test: mtime changes

* test: check should be skipped

* docs: remove fulfilled todos

* fix: remove `.only`

* docs: todo

* docs: correct test names

* test: workspace structure changes

* test: packages list cache

* test: add

* refactor: divide a test file into 2

* docs: consistent wordings

* refactor: clearer error messages

* fix: ignore check in recursive nested scripts

* test: no dependencies

* test: print error messages on failures

* test: improve stdout/stderr in error messages

* docs: consistent wordings

* docs: clarify what did what

* test: nested script

* docs: consistent test descriptions

* docs(changeset): correction

* fix: save catalogs to packages list

* test: catalogs

* test: fix

* test: fix windows

* refactor: remove unused option field

* refactor: prefer `!= null`

* feat: use `node_modules` instead

* refactor: rename a package

* refactor: apply suggestion

* refactor: remove workspaceDir

* refactor: move `shouldRunCheck` to `exec`

* feat: rename config key

* refactor: rename a test dir

* refactor: correct grammar

* refactor: make loadPackagesList sync

* test: multiple lockfiles

* feat: prevent deletion of `node_modules`

* feat: skip checking on filtered install

* fix: accidentally dropping catalogs

* refactor: remove unnecessary `Promise.all`

* refactor: use `virtualStoreDir` from config

* refactor: split `opts` into `ctx` and `opts`

* test: fix

* style: fix eslint

* test: fix windows

* feat(exec): add `verifyDepsBeforeRun` to `exec`

* refactor: sync stat

* feat: stop ignoring filtered install

* test: filtered install

* refactor: rearrange imports

* feat: rename "packages list" to "workspace state"

* test: fix

* fix: workspace state on failed install
This commit is contained in:
Khải
2024-11-15 07:01:09 +07:00
committed by GitHub
parent 5967839796
commit 19d5b51558
55 changed files with 3090 additions and 158 deletions

View File

@@ -0,0 +1,5 @@
---
"@pnpm/constants": minor
---
Add `MANIFEST_BASE_NAMES`

View File

@@ -0,0 +1,6 @@
---
"@pnpm/workspace.state": major
"@pnpm/deps.status": major
---
Initial Release

View File

@@ -0,0 +1,5 @@
---
"@pnpm/lockfile.verification": minor
---
Export `linkedPackagesAreUpToDate` and `getWorkspacePackagesByDirectory`

View File

@@ -0,0 +1,7 @@
---
"@pnpm/plugin-commands-script-runners": minor
"@pnpm/config": minor
"pnpm": minor
---
Add a feature to check dependencies before running scripts [#8585](https://github.com/pnpm/pnpm/issues/8585).

View File

@@ -0,0 +1,5 @@
---
"@pnpm/plugin-commands-installation": minor
---
Save a cache of packages list on every recursive install

View File

@@ -38,6 +38,7 @@ export interface Config {
global?: boolean
dir: string
bin: string
verifyDepsBeforeRun?: boolean
ignoreDepScripts?: boolean
ignoreScripts?: boolean
ignoreCompatibilityDb?: boolean

15
deps/status/README.md vendored Normal file
View File

@@ -0,0 +1,15 @@
# @pnpm/deps.status
> Check dependencies status
[![npm version](https://img.shields.io/npm/v/@pnpm/deps.status.svg)](https://www.npmjs.com/package/@pnpm/deps.status)
## Installation
```sh
pnpm add @pnpm/deps.status
```
## License
MIT

66
deps/status/package.json vendored Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "@pnpm/deps.status",
"version": "0.0.0",
"description": "Check dependencies status",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"lib",
"!*.map"
],
"engines": {
"node": ">=18.12"
},
"scripts": {
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"_test": "jest",
"test": "pnpm run compile && pnpm run _test",
"prepublishOnly": "pnpm run compile",
"start": "tsc --watch",
"compile": "tsc --build && pnpm run lint --fix"
},
"repository": "https://github.com/pnpm/pnpm/blob/main/deps/status",
"keywords": [
"pnpm10",
"pnpm",
"scripts"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/deps/status#readme",
"dependencies": {
"@pnpm/config": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/crypto.object-hasher": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/get-context": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.settings-checker": "workspace:*",
"@pnpm/lockfile.verification": "workspace:*",
"@pnpm/parse-overrides": "workspace:*",
"@pnpm/resolver-base": "workspace:*",
"@pnpm/types": "workspace:*",
"@pnpm/workspace.find-packages": "workspace:*",
"@pnpm/workspace.read-manifest": "workspace:*",
"@pnpm/workspace.state": "workspace:*",
"ramda": "catalog:"
},
"devDependencies": {
"@pnpm/deps.status": "workspace:*",
"@pnpm/prepare": "workspace:*",
"@pnpm/write-project-manifest": "workspace:*",
"@types/ramda": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": "^5.1.0"
},
"funding": "https://opencollective.com/pnpm",
"exports": {
".": "./lib/index.js"
},
"jest": {
"preset": "@pnpm/jest-config"
}
}

20
deps/status/src/assertLockfilesEqual.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
import { PnpmError } from '@pnpm/error'
import { type Lockfile } from '@pnpm/lockfile.fs'
import { equals } from 'ramda'
export function assertLockfilesEqual (currentLockfile: Lockfile | null, wantedLockfile: Lockfile, wantedLockfileDir: string): void {
if (!currentLockfile) {
// make sure that no importer of wantedLockfile has any dependency
for (const [name, snapshot] of Object.entries(wantedLockfile.importers)) {
if (!equals(snapshot.specifiers, {})) {
throw new PnpmError('RUN_CHECK_DEPS_NO_DEPS', `Project ${name} requires dependencies but none was installed.`, {
hint: 'Run `pnpm install` to install dependencies',
})
}
}
} else if (!equals(currentLockfile, wantedLockfile)) {
throw new PnpmError('RUN_CHECK_DEPS_OUTDATED_DEPS', `The installed dependencies in the modules directory is not up-to-date with the lockfile in ${wantedLockfileDir}.`, {
hint: 'Run `pnpm install` to update dependencies.',
})
}
}

View File

@@ -0,0 +1,406 @@
import fs from 'fs'
import path from 'path'
import util from 'util'
import equals from 'ramda/src/equals'
import isEmpty from 'ramda/src/isEmpty'
import filter from 'ramda/src/filter'
import once from 'ramda/src/once'
import { type Config, type OptionsFromRootManifest, getOptionsFromRootManifest } from '@pnpm/config'
import { MANIFEST_BASE_NAMES, WANTED_LOCKFILE } from '@pnpm/constants'
import { hashObjectNullableWithPrefix } from '@pnpm/crypto.object-hasher'
import { PnpmError } from '@pnpm/error'
import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context'
import {
type Lockfile,
getLockfileImporterId,
readCurrentLockfile,
readWantedLockfile,
} from '@pnpm/lockfile.fs'
import {
calcPatchHashes,
createOverridesMapFromParsed,
getOutdatedLockfileSetting,
} from '@pnpm/lockfile.settings-checker'
import {
linkedPackagesAreUpToDate,
getWorkspacePackagesByDirectory,
satisfiesPackageManifest,
} from '@pnpm/lockfile.verification'
import { globalWarn, logger } from '@pnpm/logger'
import { parseOverrides } from '@pnpm/parse-overrides'
import { type WorkspacePackages } from '@pnpm/resolver-base'
import {
type DependencyManifest,
type Project,
type ProjectId,
type ProjectManifest,
} from '@pnpm/types'
import { findWorkspacePackages } from '@pnpm/workspace.find-packages'
import { readWorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { loadWorkspaceState, updateWorkspaceState } from '@pnpm/workspace.state'
import { assertLockfilesEqual } from './assertLockfilesEqual'
import { statManifestFile } from './statManifestFile'
export type CheckLockfilesUpToDateOptions = Pick<Config,
| 'allProjects'
| 'autoInstallPeers'
| 'catalogs'
| 'excludeLinksFromLockfile'
| 'linkWorkspacePackages'
| 'hooks'
| 'peersSuffixMaxLength'
| 'rootProjectManifest'
| 'rootProjectManifestDir'
| 'sharedWorkspaceLockfile'
| 'virtualStoreDir'
| 'workspaceDir'
>
export async function checkLockfilesUpToDate (opts: CheckLockfilesUpToDateOptions): Promise<void> {
const {
allProjects,
autoInstallPeers,
catalogs,
excludeLinksFromLockfile,
linkWorkspacePackages,
rootProjectManifest,
rootProjectManifestDir,
sharedWorkspaceLockfile,
workspaceDir,
} = opts
const rootManifestOptions = rootProjectManifest && rootProjectManifestDir
? getOptionsFromRootManifest(rootProjectManifestDir, rootProjectManifest)
: undefined
if (allProjects && workspaceDir) {
const workspaceState = loadWorkspaceState(workspaceDir)
if (!workspaceState) {
throw new PnpmError('RUN_CHECK_DEPS_NO_CACHE', 'Cannot check whether dependencies are outdated', {
hint: 'Run `pnpm install` to create the cache',
})
}
if (!equals(
filter(value => value != null, workspaceState.catalogs ?? {}),
filter(value => value != null, catalogs ?? {})
)) {
throw new PnpmError('RUN_CHECK_DEPS_OUTDATED', 'Catalogs cache outdated', {
hint: 'Run `pnpm install` to update the catalogs cache',
})
}
const currentProjectRootDirs = allProjects.map(project => project.rootDir).sort()
if (!equals(workspaceState.projectRootDirs, currentProjectRootDirs)) {
throw new PnpmError('RUN_CHECK_DEPS_WORKSPACE_STRUCTURE_CHANGED', 'The workspace structure has changed since last install', {
hint: 'Run `pnpm install` to update the workspace structure and dependencies tree',
})
}
const allManifestStats = await Promise.all(allProjects.map(async project => {
const manifestStats = await statManifestFile(project.rootDir)
if (!manifestStats) {
// this error should not happen
throw new Error(`Cannot find one of ${MANIFEST_BASE_NAMES.join(', ')} in ${project.rootDir}`)
}
return { project, manifestStats }
}))
const modifiedProjects = allManifestStats.filter(
({ manifestStats }) =>
manifestStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp
)
if (modifiedProjects.length === 0) {
logger.debug({ msg: 'No manifest files were modified since the last validation. Exiting check.' })
return
}
logger.debug({ msg: 'Some manifest files were modified since the last validation. Continuing check.' })
let readWantedLockfileAndDir: (projectDir: string) => Promise<{
wantedLockfile: Lockfile
wantedLockfileDir: string
}>
if (sharedWorkspaceLockfile) {
let wantedLockfileStats: fs.Stats
try {
wantedLockfileStats = fs.statSync(path.join(workspaceDir, WANTED_LOCKFILE))
} catch (error) {
if (util.types.isNativeError(error) && 'code' in error && error.code === 'ENOENT') {
return throwLockfileNotFound(workspaceDir)
} else {
throw error
}
}
const wantedLockfilePromise = readWantedLockfile(workspaceDir, { ignoreIncompatible: false })
if (wantedLockfileStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp) {
const virtualStoreDir = opts.virtualStoreDir ?? path.join(workspaceDir, 'node_modules', '.pnpm')
const currentLockfile = await readCurrentLockfile(virtualStoreDir, { ignoreIncompatible: false })
const wantedLockfile = (await wantedLockfilePromise) ?? throwLockfileNotFound(workspaceDir)
assertLockfilesEqual(currentLockfile, wantedLockfile, workspaceDir)
}
readWantedLockfileAndDir = async () => ({
wantedLockfile: (await wantedLockfilePromise) ?? throwLockfileNotFound(workspaceDir),
wantedLockfileDir: workspaceDir,
})
} else {
readWantedLockfileAndDir = async wantedLockfileDir => {
const wantedLockfilePromise = readWantedLockfile(wantedLockfileDir, { ignoreIncompatible: false })
const wantedLockfileStats = await statIfExists(path.join(wantedLockfileDir, WANTED_LOCKFILE))
if (!wantedLockfileStats) return throwLockfileNotFound(wantedLockfileDir)
if (wantedLockfileStats.mtime.valueOf() > workspaceState.lastValidatedTimestamp) {
const virtualStoreDir = opts.virtualStoreDir ?? path.join(wantedLockfileDir, 'node_modules', '.pnpm')
const currentLockfile = await readCurrentLockfile(virtualStoreDir, { ignoreIncompatible: false })
const wantedLockfile = (await wantedLockfilePromise) ?? throwLockfileNotFound(wantedLockfileDir)
assertLockfilesEqual(currentLockfile, wantedLockfile, wantedLockfileDir)
}
return {
wantedLockfile: (await wantedLockfilePromise) ?? throwLockfileNotFound(wantedLockfileDir),
wantedLockfileDir,
}
}
}
type GetProjectId = (project: Pick<Project, 'rootDir'>) => ProjectId
const getProjectId: GetProjectId = sharedWorkspaceLockfile
? project => getLockfileImporterId(workspaceDir, project.rootDir)
: () => '.' as ProjectId
const getWorkspacePackages = once(arrayOfWorkspacePackagesToMap.bind(null, allProjects))
const getManifestsByDir = once(() => getWorkspacePackagesByDirectory(getWorkspacePackages()))
const assertCtx: AssertWantedLockfileUpToDateContext = {
autoInstallPeers,
config: opts,
excludeLinksFromLockfile,
linkWorkspacePackages,
getManifestsByDir,
getWorkspacePackages,
rootDir: workspaceDir,
rootManifestOptions,
}
await Promise.all(modifiedProjects.map(async ({ project }) => {
const { wantedLockfile, wantedLockfileDir } = await readWantedLockfileAndDir(project.rootDir)
await assertWantedLockfileUpToDate(assertCtx, {
projectDir: project.rootDir,
projectId: getProjectId(project),
projectManifest: project.manifest,
wantedLockfile,
wantedLockfileDir,
})
}))
// update lastValidatedTimestamp to prevent pointless repeat
await updateWorkspaceState({
allProjects,
catalogs,
lastValidatedTimestamp: Date.now(),
workspaceDir,
})
return
}
if (!allProjects) {
const workspaceRoot = workspaceDir ?? rootProjectManifestDir
const workspaceManifest = await readWorkspaceManifest(workspaceRoot)
if (workspaceManifest ?? workspaceDir) {
const allProjects = await findWorkspacePackages(rootProjectManifestDir, {
patterns: workspaceManifest?.packages,
sharedWorkspaceLockfile,
})
return checkLockfilesUpToDate({
...opts,
allProjects,
})
}
} else {
// this error shouldn't happen
throw new Error('Impossible variant: allProjects is defined but workspaceDir is undefined')
}
if (rootProjectManifest && rootProjectManifestDir) {
const virtualStoreDir = path.join(rootProjectManifestDir, 'node_modules', '.pnpm')
const currentLockfilePromise = readCurrentLockfile(virtualStoreDir, { ignoreIncompatible: false })
const wantedLockfilePromise = readWantedLockfile(rootProjectManifestDir, { ignoreIncompatible: false })
const [
currentLockfileStats,
wantedLockfileStats,
manifestStats,
] = await Promise.all([
statIfExists(path.join(virtualStoreDir, 'lock.yaml')),
statIfExists(path.join(rootProjectManifestDir, WANTED_LOCKFILE)),
statManifestFile(rootProjectManifestDir),
])
if (!wantedLockfileStats) return throwLockfileNotFound(rootProjectManifestDir)
if (currentLockfileStats && wantedLockfileStats.mtime.valueOf() > currentLockfileStats.mtime.valueOf()) {
const currentLockfile = await currentLockfilePromise
const wantedLockfile = (await wantedLockfilePromise) ?? throwLockfileNotFound(rootProjectManifestDir)
assertLockfilesEqual(currentLockfile, wantedLockfile, rootProjectManifestDir)
}
if (!manifestStats) {
// this error should not happen
throw new Error(`Cannot find one of ${MANIFEST_BASE_NAMES.join(', ')} in ${rootProjectManifestDir}`)
}
if (manifestStats.mtime.valueOf() > wantedLockfileStats.mtime.valueOf()) {
logger.debug({ msg: 'The manifest is newer than the lockfile. Continuing check.' })
await assertWantedLockfileUpToDate({
autoInstallPeers,
config: opts,
excludeLinksFromLockfile,
linkWorkspacePackages,
getManifestsByDir: () => ({}),
getWorkspacePackages: () => undefined,
rootDir: rootProjectManifestDir,
rootManifestOptions,
}, {
projectDir: rootProjectManifestDir,
projectId: '.' as ProjectId,
projectManifest: rootProjectManifest,
wantedLockfile: (await wantedLockfilePromise) ?? throwLockfileNotFound(rootProjectManifestDir),
wantedLockfileDir: rootProjectManifestDir,
})
} else if (currentLockfileStats) {
logger.debug({ msg: 'The manifest file is not newer than the lockfile. Exiting check.' })
} else {
const wantedLockfile = (await wantedLockfilePromise) ?? throwLockfileNotFound(rootProjectManifestDir)
if (!isEmpty(wantedLockfile.packages ?? {})) {
throw new PnpmError('RUN_CHECK_DEPS_NO_DEPS', 'The lockfile requires dependencies but none were installed', {
hint: 'Run `pnpm install` to install dependencies',
})
}
}
return
}
// `opts.allProject` being `undefined` means that the run command was not run with `--recursive`.
// `rootProjectManifest` being `undefined` means that there's no root manifest.
// Both means that `pnpm run` would fail, so checking lockfiles here is pointless.
globalWarn('Skipping check.')
}
interface AssertWantedLockfileUpToDateContext {
autoInstallPeers?: boolean
config: CheckLockfilesUpToDateOptions
excludeLinksFromLockfile?: boolean
linkWorkspacePackages: boolean | 'deep'
getManifestsByDir: () => Record<string, DependencyManifest>
getWorkspacePackages: () => WorkspacePackages | undefined
rootDir: string
rootManifestOptions: OptionsFromRootManifest | undefined
}
interface AssertWantedLockfileUpToDateOptions {
projectDir: string
projectId: ProjectId
projectManifest: ProjectManifest
wantedLockfile: Lockfile
wantedLockfileDir: string
}
async function assertWantedLockfileUpToDate (
ctx: AssertWantedLockfileUpToDateContext,
opts: AssertWantedLockfileUpToDateOptions
): Promise<void> {
const {
autoInstallPeers,
config,
excludeLinksFromLockfile,
linkWorkspacePackages,
getManifestsByDir,
getWorkspacePackages,
rootDir,
rootManifestOptions,
} = ctx
const {
projectDir,
projectId,
projectManifest,
wantedLockfile,
wantedLockfileDir,
} = opts
const [
patchedDependencies,
pnpmfileChecksum,
] = await Promise.all([
calcPatchHashes(rootManifestOptions?.patchedDependencies ?? {}, rootDir),
config.hooks?.calculatePnpmfileChecksum?.(),
])
const outdatedLockfileSettingName = getOutdatedLockfileSetting(wantedLockfile, {
autoInstallPeers: config.autoInstallPeers,
excludeLinksFromLockfile: config.excludeLinksFromLockfile,
peersSuffixMaxLength: config.peersSuffixMaxLength,
overrides: createOverridesMapFromParsed(parseOverrides(rootManifestOptions?.overrides ?? {}, config.catalogs)),
ignoredOptionalDependencies: rootManifestOptions?.ignoredOptionalDependencies?.sort(),
packageExtensionsChecksum: hashObjectNullableWithPrefix(rootManifestOptions?.packageExtensions),
patchedDependencies,
pnpmfileChecksum,
})
if (outdatedLockfileSettingName) {
throw new PnpmError('RUN_CHECK_DEPS_OUTDATED_LOCKFILE', `Setting ${outdatedLockfileSettingName} of lockfile in ${wantedLockfileDir} is outdated`, {
hint: 'Run `pnpm install` to update the lockfile',
})
}
if (!satisfiesPackageManifest(
{
autoInstallPeers,
excludeLinksFromLockfile,
},
wantedLockfile.importers[projectId],
projectManifest
).satisfies) {
throw new PnpmError('RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST', `The lockfile in ${wantedLockfileDir} does not satisfy project of id ${projectId}`, {
hint: 'Run `pnpm install` to update the lockfile',
})
}
if (!await linkedPackagesAreUpToDate({
linkWorkspacePackages: !!linkWorkspacePackages,
lockfileDir: wantedLockfileDir,
manifestsByDir: getManifestsByDir(),
workspacePackages: getWorkspacePackages(),
lockfilePackages: wantedLockfile.packages,
}, {
dir: projectDir,
manifest: projectManifest,
snapshot: wantedLockfile.importers[projectId],
})) {
throw new PnpmError('RUN_CHECK_DEPS_LINKED_PKGS_OUTDATED', `The linked packages by ${projectDir} is outdated`, {
hint: 'Run `pnpm install` to update the packages',
})
}
}
async function statIfExists (filePath: string): Promise<fs.Stats | undefined> {
let stats: fs.Stats
try {
stats = await fs.promises.stat(filePath)
} catch (error) {
if (util.types.isNativeError(error) && 'code' in error && error.code === 'ENOENT') {
return undefined
}
throw error
}
return stats
}
function throwLockfileNotFound (wantedLockfileDir: string): never {
throw new PnpmError('RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND', `Cannot find a lockfile in ${wantedLockfileDir}`, {
hint: 'Run `pnpm install` to create the lockfile',
})
}

1
deps/status/src/index.ts vendored Normal file
View File

@@ -0,0 +1 @@
export { type CheckLockfilesUpToDateOptions, checkLockfilesUpToDate } from './checkLockfilesUpToDate'

21
deps/status/src/statManifestFile.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
import fs from 'fs'
import path from 'path'
import util from 'util'
import { MANIFEST_BASE_NAMES } from '@pnpm/constants'
export async function statManifestFile (projectRootDir: string): Promise<fs.Stats | undefined> {
const attempts = await Promise.all(MANIFEST_BASE_NAMES.map(async baseName => {
const manifestPath = path.join(projectRootDir, baseName)
let stats: fs.Stats
try {
stats = await fs.promises.stat(manifestPath)
} catch (error) {
if (util.types.isNativeError(error) && 'code' in error && error.code === 'ENOENT') {
return undefined
}
throw error
}
return stats
}))
return attempts.find(stats => stats != null)
}

View File

@@ -0,0 +1,82 @@
import { LOCKFILE_VERSION } from '@pnpm/constants'
import { type Lockfile } from '@pnpm/lockfile.fs'
import { type ProjectId } from '@pnpm/types'
import { assertLockfilesEqual } from '../src/assertLockfilesEqual'
test('if wantedLockfile does not have any specifier, currentLockfile is allowed to be null', () => {
assertLockfilesEqual(null, {
lockfileVersion: LOCKFILE_VERSION,
importers: {
['.' as ProjectId]: {
specifiers: {},
},
},
}, '<LOCKFILE_DIR>')
})
test('should throw if wantedLockfile has specifiers but currentLockfile is null', () => {
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
expect(() => assertLockfilesEqual(null, {
lockfileVersion: LOCKFILE_VERSION,
importers: {
['.' as ProjectId]: {
specifiers: {
foo: '^1.0.0',
},
dependencies: {
foo: '1.0.1',
},
},
},
}, '<LOCKFILE_DIR>')).toThrow('Project . requires dependencies but none was installed.')
})
test('should not throw if wantedLockfile and currentLockfile are equal', () => {
const lockfile = (): Lockfile => ({
lockfileVersion: LOCKFILE_VERSION,
importers: {
['.' as ProjectId]: {
specifiers: {
foo: '^1.0.0',
},
dependencies: {
foo: '1.0.1',
},
},
},
})
assertLockfilesEqual(lockfile(), lockfile(), '<LOCKFILE_DIR>')
})
test('should throw if wantedLockfile and currentLockfile are not equal', () => {
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
expect(() => assertLockfilesEqual(
{
lockfileVersion: LOCKFILE_VERSION,
importers: {
['.' as ProjectId]: {
specifiers: {
foo: '^1.0.0',
},
dependencies: {
foo: '1.0.1',
},
},
},
},
{
lockfileVersion: LOCKFILE_VERSION,
importers: {
['.' as ProjectId]: {
specifiers: {
foo: '^1.0.0',
},
dependencies: {
foo: '1.1.0',
},
},
},
},
'<LOCKFILE_DIR>')
).toThrow('The installed dependencies in the modules directory is not up-to-date with the lockfile in <LOCKFILE_DIR>.')
})

View File

@@ -0,0 +1,17 @@
import { MANIFEST_BASE_NAMES } from '@pnpm/constants'
import { prepareEmpty } from '@pnpm/prepare'
import { writeProjectManifest } from '@pnpm/write-project-manifest'
import { statManifestFile } from '../src/statManifestFile'
test.each(MANIFEST_BASE_NAMES)('load %s', async baseName => {
prepareEmpty()
await writeProjectManifest(`foo/bar/${baseName}`, { name: 'foo', version: '1.0.0' })
const stats = await statManifestFile('foo/bar')
expect(stats).toBeDefined()
expect(stats?.isFile()).toBe(true)
})
test('should return undefined if no manifest is found', async () => {
prepareEmpty()
expect(await statManifestFile('.')).toBeUndefined()
})

17
deps/status/test/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../test.lib",
"rootDir": "."
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

61
deps/status/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,61 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../__utils__/prepare"
},
{
"path": "../../config/config"
},
{
"path": "../../config/parse-overrides"
},
{
"path": "../../crypto/object-hasher"
},
{
"path": "../../lockfile/fs"
},
{
"path": "../../lockfile/settings-checker"
},
{
"path": "../../lockfile/verification"
},
{
"path": "../../packages/constants"
},
{
"path": "../../packages/error"
},
{
"path": "../../packages/types"
},
{
"path": "../../pkg-manager/get-context"
},
{
"path": "../../pkg-manifest/write-project-manifest"
},
{
"path": "../../resolving/resolver-base"
},
{
"path": "../../workspace/find-packages"
},
{
"path": "../../workspace/read-manifest"
},
{
"path": "../../workspace/state"
}
]
}

8
deps/status/tsconfig.lint.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -51,6 +51,7 @@
"@pnpm/config": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/crypto.hash": "workspace:*",
"@pnpm/deps.status": "workspace:*",
"@pnpm/env.path": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/lifecycle": "workspace:*",

View File

@@ -11,6 +11,7 @@ import { sortPackages } from '@pnpm/sort-packages'
import { type Project, type ProjectsGraph, type ProjectRootDir, type ProjectRootDirRealPath } from '@pnpm/types'
import execa from 'execa'
import pLimit from 'p-limit'
import { type CheckLockfilesUpToDateOptions, checkLockfilesUpToDate } from '@pnpm/deps.status'
import { prependDirsToPath } from '@pnpm/env.path'
import pick from 'ramda/src/pick'
import renderHelp from 'render-help'
@@ -26,6 +27,7 @@ import { PnpmError } from '@pnpm/error'
import which from 'which'
import writeJsonFile from 'write-json-file'
import { getNearestProgram, getNearestScript } from './buildCommandNotFoundHint'
import { DISABLE_DEPS_CHECK_ENV, SKIP_ENV_KEY } from './shouldRunCheck'
export const shorthands: Record<string, string | string[]> = {
parallel: runShorthands.parallel,
@@ -157,8 +159,12 @@ export type ExecOpts = Required<Pick<Config, 'selectedProjectsGraph'>> & {
| 'recursive'
| 'reporterHidePrefix'
| 'userAgent'
| 'verifyDepsBeforeRun'
| 'workspaceDir'
>
> & (
| { verifyDepsBeforeRun?: false }
| { verifyDepsBeforeRun: true } & CheckLockfilesUpToDateOptions
)
export async function handler (
opts: ExecOpts,
@@ -170,6 +176,10 @@ export async function handler (
}
const limitRun = pLimit(opts.workspaceConcurrency ?? 4)
if (opts.verifyDepsBeforeRun && !process.env[SKIP_ENV_KEY]) {
await checkLockfilesUpToDate(opts)
}
let chunks!: ProjectRootDir[][]
if (opts.recursive) {
chunks = opts.sort
@@ -238,6 +248,7 @@ export async function handler (
...extraEnv,
PNPM_PACKAGE_NAME: opts.selectedProjectsGraph[prefix]?.package.manifest.name,
...(opts.nodeOptions ? { NODE_OPTIONS: opts.nodeOptions } : {}),
...opts.verifyDepsBeforeRun ? DISABLE_DEPS_CHECK_ENV : undefined,
},
prependPaths,
userAgent: opts.userAgent,

View File

@@ -9,6 +9,7 @@ import { type CompletionFunc } from '@pnpm/command'
import { prepareExecutionEnv } from '@pnpm/plugin-commands-env'
import { FILTERING, UNIVERSAL_OPTIONS } from '@pnpm/common-cli-options-help'
import { type Config, types as allTypes } from '@pnpm/config'
import { type CheckLockfilesUpToDateOptions, checkLockfilesUpToDate } from '@pnpm/deps.status'
import { PnpmError } from '@pnpm/error'
import {
runLifecycleHook,
@@ -23,6 +24,7 @@ import { runRecursive, type RecursiveRunOpts, getSpecifiedScripts as getSpecifie
import { existsInDir } from './existsInDir'
import { handler as exec } from './exec'
import { buildCommandNotFoundHint } from './buildCommandNotFoundHint'
import { DISABLE_DEPS_CHECK_ENV, shouldRunCheck } from './shouldRunCheck'
export const IF_PRESENT_OPTION: Record<string, unknown> = {
'if-present': Boolean,
@@ -158,6 +160,7 @@ export type RunOpts =
& { recursive?: boolean }
& Pick<Config,
| 'bin'
| 'verifyDepsBeforeRun'
| 'dir'
| 'enablePrePostScripts'
| 'engineStrict'
@@ -181,6 +184,10 @@ export type RunOpts =
}
fallbackCommandUsed?: boolean
}
& (
| { verifyDepsBeforeRun?: false }
| { verifyDepsBeforeRun: true } & CheckLockfilesUpToDateOptions
)
export async function handler (
opts: RunOpts,
@@ -188,8 +195,21 @@ export async function handler (
): Promise<string | { exitCode: number } | undefined> {
let dir: string
let [scriptName, ...passedThruArgs] = params
// verifyDepsBeforeRun is outside of shouldRunCheck because TypeScript's tagged union
// only works when the tag is directly placed in the condition.
if (opts.verifyDepsBeforeRun && shouldRunCheck(process.env, scriptName)) {
await checkLockfilesUpToDate(opts)
}
if (opts.recursive) {
if (scriptName || Object.keys(opts.selectedProjectsGraph).length > 1) {
if (opts.verifyDepsBeforeRun) {
opts.extraEnv = {
...opts.extraEnv,
...DISABLE_DEPS_CHECK_ENV,
}
}
return runRecursive(params, opts) as Promise<undefined>
}
dir = Object.keys(opts.selectedProjectsGraph)[0]
@@ -250,6 +270,7 @@ so you may run "pnpm -w run ${scriptName}"`,
const extraEnv = {
...opts.extraEnv,
...(opts.nodeOptions ? { NODE_OPTIONS: opts.nodeOptions } : {}),
...opts.verifyDepsBeforeRun ? DISABLE_DEPS_CHECK_ENV : undefined,
}
const lifecycleOpts: RunLifecycleHookOptions = {

View File

@@ -0,0 +1,22 @@
// The scripts that `pnpm run` executes are likely to also execute other `pnpm run`.
// We don't want this potentially expensive check to repeat.
// The solution is to use an env key to disable the check.
export const SKIP_ENV_KEY = 'pnpm_run_skip_deps_check'
export const DISABLE_DEPS_CHECK_ENV = {
[SKIP_ENV_KEY]: 'true',
} as const satisfies Env
export interface Env extends NodeJS.ProcessEnv {
[SKIP_ENV_KEY]?: string
}
const SCRIPTS_TO_SKIP = [
'preinstall',
'install',
'postinstall',
'preuninstall',
'uninstall',
'postuninstall',
]
export const shouldRunCheck = (env: Env, scriptName: string): boolean => !env[SKIP_ENV_KEY] && !SCRIPTS_TO_SKIP.includes(scriptName)

View File

@@ -0,0 +1,20 @@
import { DISABLE_DEPS_CHECK_ENV, shouldRunCheck } from '../src/shouldRunCheck'
test('should return true if skip env is not defined and script name is not special', () => {
expect(shouldRunCheck({}, 'start')).toBe(true)
})
test('should return false if skip env is defined', () => {
expect(shouldRunCheck({ ...DISABLE_DEPS_CHECK_ENV }, 'start')).toBe(false)
})
test.each([
'preinstall',
'install',
'postinstall',
'preuninstall',
'uninstall',
'postuninstall',
])('should return false if script name is %p', scriptName => {
expect(shouldRunCheck({}, scriptName)).toBe(false)
})

View File

@@ -30,6 +30,9 @@
{
"path": "../../crypto/hash"
},
{
"path": "../../deps/status"
},
{
"path": "../../env/path"
},

View File

@@ -1,29 +1,18 @@
import path from 'path'
import { type Catalogs } from '@pnpm/catalogs.types'
import { type ProjectOptions } from '@pnpm/get-context'
import {
type PackageSnapshot,
type Lockfile,
type ProjectSnapshot,
type PackageSnapshots,
} from '@pnpm/lockfile.types'
import { refIsLocalDirectory, refIsLocalTarball } from '@pnpm/lockfile.utils'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import { refToRelative } from '@pnpm/dependency-path'
import { type DirectoryResolution, type WorkspacePackages } from '@pnpm/resolver-base'
import {
DEPENDENCIES_FIELDS,
DEPENDENCIES_OR_PEER_FIELDS,
type DependencyManifest,
type ProjectId,
type ProjectManifest,
} from '@pnpm/types'
import { refIsLocalTarball } from '@pnpm/lockfile.utils'
import { type WorkspacePackages } from '@pnpm/resolver-base'
import { DEPENDENCIES_FIELDS, type ProjectId } from '@pnpm/types'
import pEvery from 'p-every'
import any from 'ramda/src/any'
import isEmpty from 'ramda/src/isEmpty'
import semver from 'semver'
import getVersionSelectorType from 'version-selector-type'
import { allCatalogsAreUpToDate } from './allCatalogsAreUpToDate'
import { getWorkspacePackagesByDirectory } from './getWorkspacePackagesByDirectory'
import { linkedPackagesAreUpToDate } from './linkedPackagesAreUpToDate'
import { satisfiesPackageManifest } from './satisfiesPackageManifest'
export async function allProjectsAreUpToDate (
@@ -73,138 +62,6 @@ export async function allProjectsAreUpToDate (
})
}
function getWorkspacePackagesByDirectory (workspacePackages: WorkspacePackages): Record<string, DependencyManifest> {
const workspacePackagesByDirectory: Record<string, DependencyManifest> = {}
if (workspacePackages) {
for (const pkgVersions of workspacePackages.values()) {
for (const { rootDir, manifest } of pkgVersions.values()) {
workspacePackagesByDirectory[rootDir] = manifest
}
}
}
return workspacePackagesByDirectory
}
async function linkedPackagesAreUpToDate (
{
linkWorkspacePackages,
manifestsByDir,
workspacePackages,
lockfilePackages,
lockfileDir,
}: {
linkWorkspacePackages: boolean
manifestsByDir: Record<string, DependencyManifest>
workspacePackages: WorkspacePackages
lockfilePackages?: PackageSnapshots
lockfileDir: string
},
project: {
dir: string
manifest: ProjectManifest
snapshot: ProjectSnapshot
}
): Promise<boolean> {
return pEvery(
DEPENDENCIES_FIELDS,
(depField) => {
const lockfileDeps = project.snapshot[depField]
const manifestDeps = project.manifest[depField]
if ((lockfileDeps == null) || (manifestDeps == null)) return true
const depNames = Object.keys(lockfileDeps)
return pEvery(
depNames,
async (depName) => {
const currentSpec = manifestDeps[depName]
if (!currentSpec) return true
const lockfileRef = lockfileDeps[depName]
if (refIsLocalDirectory(project.snapshot.specifiers[depName])) {
const depPath = refToRelative(lockfileRef, depName)
return depPath != null && isLocalFileDepUpdated(lockfileDir, lockfilePackages?.[depPath])
}
const isLinked = lockfileRef.startsWith('link:')
if (
isLinked &&
(
currentSpec.startsWith('link:') ||
currentSpec.startsWith('file:') ||
currentSpec.startsWith('workspace:.')
)
) {
return true
}
// https://github.com/pnpm/pnpm/issues/6592
// if the dependency is linked and the specified version type is tag, we consider it to be up-to-date to skip full resolution.
if (isLinked && getVersionSelectorType(currentSpec)?.type === 'tag') {
return true
}
const linkedDir = isLinked
? path.join(project.dir, lockfileRef.slice(5))
: workspacePackages?.get(depName)?.get(lockfileRef)?.rootDir
if (!linkedDir) return true
if (!linkWorkspacePackages && !currentSpec.startsWith('workspace:')) {
// we found a linked dir, but we don't want to use it, because it's not specified as a
// workspace:x.x.x dependency
return true
}
const linkedPkg = manifestsByDir[linkedDir] ?? await safeReadPackageJsonFromDir(linkedDir)
const availableRange = getVersionRange(currentSpec)
// This should pass the same options to semver as @pnpm/npm-resolver
const localPackageSatisfiesRange = availableRange === '*' || availableRange === '^' || availableRange === '~' ||
linkedPkg && semver.satisfies(linkedPkg.version, availableRange, { loose: true })
if (isLinked !== localPackageSatisfiesRange) return false
return true
}
)
}
)
}
async function isLocalFileDepUpdated (lockfileDir: string, pkgSnapshot: PackageSnapshot | undefined): Promise<boolean> {
if (!pkgSnapshot) return false
const localDepDir = path.join(lockfileDir, (pkgSnapshot.resolution as DirectoryResolution).directory)
const manifest = await safeReadPackageJsonFromDir(localDepDir)
if (!manifest) return false
for (const depField of DEPENDENCIES_OR_PEER_FIELDS) {
if (depField === 'devDependencies') continue
const manifestDeps = manifest[depField] ?? {}
const lockfileDeps = pkgSnapshot[depField] ?? {}
// Lock file has more dependencies than the current manifest, e.g. some dependencies are removed.
if (Object.keys(lockfileDeps).some(depName => !manifestDeps[depName])) {
return false
}
for (const depName of Object.keys(manifestDeps)) {
// If a dependency does not exist in the lock file, e.g. a new dependency is added to the current manifest.
// We need to do full resolution again.
if (!lockfileDeps[depName]) {
return false
}
const currentSpec = manifestDeps[depName]
// We do not care about the link dependencies of local dependency.
if (currentSpec.startsWith('file:') || currentSpec.startsWith('link:') || currentSpec.startsWith('workspace:')) continue
if (semver.satisfies(lockfileDeps[depName], getVersionRange(currentSpec), { loose: true })) {
continue
} else {
return false
}
}
}
return true
}
function getVersionRange (spec: string): string {
if (spec.startsWith('workspace:')) return spec.slice(10)
if (spec.startsWith('npm:')) {
spec = spec.slice(4)
const index = spec.indexOf('@', 1)
if (index === -1) return '*'
return spec.slice(index + 1) || '*'
}
return spec
}
function hasLocalTarballDepsInRoot (importer: ProjectSnapshot): boolean {
return any(refIsLocalTarball, Object.values(importer.dependencies ?? {})) ||
any(refIsLocalTarball, Object.values(importer.devDependencies ?? {})) ||

View File

@@ -0,0 +1,14 @@
import { type WorkspacePackages } from '@pnpm/resolver-base'
import { type DependencyManifest } from '@pnpm/types'
export function getWorkspacePackagesByDirectory (workspacePackages: WorkspacePackages): Record<string, DependencyManifest> {
const workspacePackagesByDirectory: Record<string, DependencyManifest> = {}
if (workspacePackages) {
for (const pkgVersions of workspacePackages.values()) {
for (const { rootDir, manifest } of pkgVersions.values()) {
workspacePackagesByDirectory[rootDir] = manifest
}
}
}
return workspacePackagesByDirectory
}

View File

@@ -1,2 +1,4 @@
export { allProjectsAreUpToDate } from './allProjectsAreUpToDate'
export { getWorkspacePackagesByDirectory } from './getWorkspacePackagesByDirectory'
export { linkedPackagesAreUpToDate } from './linkedPackagesAreUpToDate'
export { satisfiesPackageManifest } from './satisfiesPackageManifest'

View File

@@ -0,0 +1,139 @@
import path from 'path'
import {
type PackageSnapshot,
type ProjectSnapshot,
type PackageSnapshots,
} from '@pnpm/lockfile.types'
import { refIsLocalDirectory } from '@pnpm/lockfile.utils'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import { refToRelative } from '@pnpm/dependency-path'
import { type DirectoryResolution, type WorkspacePackages } from '@pnpm/resolver-base'
import {
DEPENDENCIES_FIELDS,
DEPENDENCIES_OR_PEER_FIELDS,
type DependencyManifest,
type ProjectManifest,
} from '@pnpm/types'
import pEvery from 'p-every'
import semver from 'semver'
import getVersionSelectorType from 'version-selector-type'
export async function linkedPackagesAreUpToDate (
{
linkWorkspacePackages,
manifestsByDir,
workspacePackages,
lockfilePackages,
lockfileDir,
}: {
linkWorkspacePackages: boolean
manifestsByDir: Record<string, DependencyManifest>
workspacePackages?: WorkspacePackages
lockfilePackages?: PackageSnapshots
lockfileDir: string
},
project: {
dir: string
manifest: ProjectManifest
snapshot: ProjectSnapshot
}
): Promise<boolean> {
return pEvery(
DEPENDENCIES_FIELDS,
(depField) => {
const lockfileDeps = project.snapshot[depField]
const manifestDeps = project.manifest[depField]
if ((lockfileDeps == null) || (manifestDeps == null)) return true
const depNames = Object.keys(lockfileDeps)
return pEvery(
depNames,
async (depName) => {
const currentSpec = manifestDeps[depName]
if (!currentSpec) return true
const lockfileRef = lockfileDeps[depName]
if (refIsLocalDirectory(project.snapshot.specifiers[depName])) {
const depPath = refToRelative(lockfileRef, depName)
return depPath != null && isLocalFileDepUpdated(lockfileDir, lockfilePackages?.[depPath])
}
const isLinked = lockfileRef.startsWith('link:')
if (
isLinked &&
(
currentSpec.startsWith('link:') ||
currentSpec.startsWith('file:') ||
currentSpec.startsWith('workspace:.')
)
) {
return true
}
// https://github.com/pnpm/pnpm/issues/6592
// if the dependency is linked and the specified version type is tag, we consider it to be up-to-date to skip full resolution.
if (isLinked && getVersionSelectorType(currentSpec)?.type === 'tag') {
return true
}
const linkedDir = isLinked
? path.join(project.dir, lockfileRef.slice(5))
: workspacePackages?.get(depName)?.get(lockfileRef)?.rootDir
if (!linkedDir) return true
if (!linkWorkspacePackages && !currentSpec.startsWith('workspace:')) {
// we found a linked dir, but we don't want to use it, because it's not specified as a
// workspace:x.x.x dependency
return true
}
const linkedPkg = manifestsByDir[linkedDir] ?? await safeReadPackageJsonFromDir(linkedDir)
const availableRange = getVersionRange(currentSpec)
// This should pass the same options to semver as @pnpm/npm-resolver
const localPackageSatisfiesRange = availableRange === '*' || availableRange === '^' || availableRange === '~' ||
linkedPkg && semver.satisfies(linkedPkg.version, availableRange, { loose: true })
if (isLinked !== localPackageSatisfiesRange) return false
return true
}
)
}
)
}
async function isLocalFileDepUpdated (lockfileDir: string, pkgSnapshot: PackageSnapshot | undefined): Promise<boolean> {
if (!pkgSnapshot) return false
const localDepDir = path.join(lockfileDir, (pkgSnapshot.resolution as DirectoryResolution).directory)
const manifest = await safeReadPackageJsonFromDir(localDepDir)
if (!manifest) return false
for (const depField of DEPENDENCIES_OR_PEER_FIELDS) {
if (depField === 'devDependencies') continue
const manifestDeps = manifest[depField] ?? {}
const lockfileDeps = pkgSnapshot[depField] ?? {}
// Lock file has more dependencies than the current manifest, e.g. some dependencies are removed.
if (Object.keys(lockfileDeps).some(depName => !manifestDeps[depName])) {
return false
}
for (const depName of Object.keys(manifestDeps)) {
// If a dependency does not exist in the lock file, e.g. a new dependency is added to the current manifest.
// We need to do full resolution again.
if (!lockfileDeps[depName]) {
return false
}
const currentSpec = manifestDeps[depName]
// We do not care about the link dependencies of local dependency.
if (currentSpec.startsWith('file:') || currentSpec.startsWith('link:') || currentSpec.startsWith('workspace:')) continue
if (semver.satisfies(lockfileDeps[depName], getVersionRange(currentSpec), { loose: true })) {
continue
} else {
return false
}
}
}
return true
}
function getVersionRange (spec: string): string {
if (spec.startsWith('workspace:')) return spec.slice(10)
if (spec.startsWith('npm:')) {
spec = spec.slice(4)
const index = spec.indexOf('@', 1)
if (index === -1) return '*'
return spec.slice(index + 1) || '*'
}
return spec
}

View File

@@ -3,6 +3,8 @@ export const LOCKFILE_MAJOR_VERSION = '9'
export const LOCKFILE_VERSION = `${LOCKFILE_MAJOR_VERSION}.0`
export const LOCKFILE_VERSION_V6 = '6.0'
export const MANIFEST_BASE_NAMES = ['package.json', 'package.json5', 'package.yaml'] as const
export const ENGINE_NAME = `${process.platform};${process.arch};node${process.version.split('.')[0].substring(1)}`
export const LAYOUT_VERSION = 5
export const STORE_VERSION = 'v10'

View File

@@ -88,6 +88,7 @@
"@pnpm/types": "workspace:*",
"@pnpm/workspace.find-packages": "workspace:*",
"@pnpm/workspace.pkgs-graph": "workspace:*",
"@pnpm/workspace.state": "workspace:*",
"@pnpm/write-project-manifest": "workspace:*",
"@yarnpkg/core": "catalog:",
"@yarnpkg/lockfile": "catalog:",

View File

@@ -22,11 +22,20 @@ import {
import { logger } from '@pnpm/logger'
import { sequenceGraph } from '@pnpm/sort-packages'
import { createPkgGraph } from '@pnpm/workspace.pkgs-graph'
import { updateWorkspaceState } from '@pnpm/workspace.state'
import isSubdir from 'is-subdir'
import { getPinnedVersion } from './getPinnedVersion'
import { getSaveType } from './getSaveType'
import { getNodeExecPath } from './nodeExecPath'
import { recursive, createMatcher, matchDependencies, makeIgnorePatterns, type UpdateDepsMatcher } from './recursive'
import {
type CommandFullName,
type RecursiveOptions,
type UpdateDepsMatcher,
createMatcher,
matchDependencies,
makeIgnorePatterns,
recursive,
} from './recursive'
import { createWorkspaceSpecs, updateToWorkspacePackagesFromManifest } from './updateWorkspaceDependencies'
const OVERWRITE_UPDATE_OPTIONS = {
@@ -40,6 +49,7 @@ export type InstallDepsOptions = Pick<Config,
| 'autoInstallPeers'
| 'bail'
| 'bin'
| 'catalogs'
| 'cliOptions'
| 'dedupePeerDependents'
| 'depth'
@@ -191,7 +201,7 @@ when running add/update with the --workspace option')
}
}
}
await recursive(allProjects,
await recursiveInstallThenUpdateWorkspaceState(allProjects,
params,
{
...opts,
@@ -316,7 +326,7 @@ when running add/update with the --workspace option')
], {
workspaceDir: opts.workspaceDir,
})
await recursive(allProjects, [], {
await recursiveInstallThenUpdateWorkspaceState(allProjects, [], {
...opts,
...OVERWRITE_UPDATE_OPTIONS,
allProjectsGraph: opts.allProjectsGraph!,
@@ -349,3 +359,19 @@ function selectProjectByDir (projects: Project[], searchedDir: string): Projects
if (project == null) return undefined
return { [searchedDir]: { dependencies: [], package: project } }
}
async function recursiveInstallThenUpdateWorkspaceState (
allProjects: Project[],
params: string[],
opts: RecursiveOptions & Pick<InstallDepsOptions, 'catalogs' | 'workspaceDir'>,
cmdFullName: CommandFullName
): Promise<boolean | string> {
const recursiveResult = await recursive(allProjects, params, opts, cmdFullName)
await updateWorkspaceState({
allProjects,
catalogs: opts.catalogs,
lastValidatedTimestamp: Date.now(),
workspaceDir: opts.workspaceDir,
})
return recursiveResult
}

View File

@@ -42,7 +42,7 @@ import { getSaveType } from './getSaveType'
import { getPinnedVersion } from './getPinnedVersion'
import { type PreferredVersions } from '@pnpm/resolver-base'
type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'bail'
| 'dedupePeerDependents'
| 'depth'
@@ -98,11 +98,13 @@ Pick<Config,
Pick<Config, 'workspaceDir'>
>
export type CommandFullName = 'install' | 'add' | 'remove' | 'update' | 'import'
export async function recursive (
allProjects: Project[],
params: string[],
opts: RecursiveOptions,
cmdFullName: 'install' | 'add' | 'remove' | 'update' | 'import'
cmdFullName: CommandFullName
): Promise<boolean | string> {
if (allProjects.length === 0) {
// It might make sense to throw an exception in this case

View File

@@ -108,6 +108,9 @@
{
"path": "../../workspace/sort-packages"
},
{
"path": "../../workspace/state"
},
{
"path": "../core"
},

95
pnpm-lock.yaml generated
View File

@@ -1784,6 +1784,70 @@ importers:
specifier: workspace:*
version: 'link:'
deps/status:
dependencies:
'@pnpm/config':
specifier: workspace:*
version: link:../../config/config
'@pnpm/constants':
specifier: workspace:*
version: link:../../packages/constants
'@pnpm/crypto.object-hasher':
specifier: workspace:*
version: link:../../crypto/object-hasher
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
'@pnpm/get-context':
specifier: workspace:*
version: link:../../pkg-manager/get-context
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../lockfile/fs
'@pnpm/lockfile.settings-checker':
specifier: workspace:*
version: link:../../lockfile/settings-checker
'@pnpm/lockfile.verification':
specifier: workspace:*
version: link:../../lockfile/verification
'@pnpm/logger':
specifier: ^5.1.0
version: 5.1.0
'@pnpm/parse-overrides':
specifier: workspace:*
version: link:../../config/parse-overrides
'@pnpm/resolver-base':
specifier: workspace:*
version: link:../../resolving/resolver-base
'@pnpm/types':
specifier: workspace:*
version: link:../../packages/types
'@pnpm/workspace.find-packages':
specifier: workspace:*
version: link:../../workspace/find-packages
'@pnpm/workspace.read-manifest':
specifier: workspace:*
version: link:../../workspace/read-manifest
'@pnpm/workspace.state':
specifier: workspace:*
version: link:../../workspace/state
ramda:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'
devDependencies:
'@pnpm/deps.status':
specifier: workspace:*
version: 'link:'
'@pnpm/prepare':
specifier: workspace:*
version: link:../../__utils__/prepare
'@pnpm/write-project-manifest':
specifier: workspace:*
version: link:../../pkg-manifest/write-project-manifest
'@types/ramda':
specifier: 'catalog:'
version: 0.29.12
env/node.fetcher:
dependencies:
'@pnpm/create-cafs-store':
@@ -2287,6 +2351,9 @@ importers:
'@pnpm/crypto.hash':
specifier: workspace:*
version: link:../../crypto/hash
'@pnpm/deps.status':
specifier: workspace:*
version: link:../../deps/status
'@pnpm/env.path':
specifier: workspace:*
version: link:../../env/path
@@ -5064,6 +5131,9 @@ importers:
'@pnpm/workspace.pkgs-graph':
specifier: workspace:*
version: link:../../workspace/pkgs-graph
'@pnpm/workspace.state':
specifier: workspace:*
version: link:../../workspace/state
'@pnpm/write-project-manifest':
specifier: workspace:*
version: link:../../pkg-manifest/write-project-manifest
@@ -5778,6 +5848,9 @@ importers:
'@pnpm/workspace.pkgs-graph':
specifier: workspace:*
version: link:../workspace/pkgs-graph
'@pnpm/workspace.state':
specifier: workspace:*
version: link:../workspace/state
'@pnpm/write-project-manifest':
specifier: workspace:*
version: link:../pkg-manifest/write-project-manifest
@@ -7800,6 +7873,28 @@ importers:
specifier: workspace:*
version: 'link:'
workspace/state:
dependencies:
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../../catalogs/types
'@pnpm/logger':
specifier: workspace:*
version: link:../../packages/logger
'@pnpm/types':
specifier: workspace:*
version: link:../../packages/types
ramda:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'
devDependencies:
'@pnpm/prepare':
specifier: workspace:*
version: link:../../__utils__/prepare
'@pnpm/workspace.state':
specifier: workspace:*
version: 'link:'
packages:
'@ampproject/remapping@2.3.0':

View File

@@ -80,6 +80,7 @@
"@pnpm/worker": "workspace:*",
"@pnpm/workspace.find-packages": "workspace:*",
"@pnpm/workspace.pkgs-graph": "workspace:*",
"@pnpm/workspace.state": "workspace:*",
"@pnpm/write-project-manifest": "workspace:*",
"@types/cross-spawn": "catalog:",
"@types/is-windows": "catalog:",

View File

@@ -4,9 +4,9 @@ import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import isWindows from 'is-windows'
import crossSpawn from 'cross-spawn'
const binDir = path.join(__dirname, '../..', isWindows() ? 'dist' : 'bin')
const pnpmBinLocation = path.join(binDir, 'pnpm.cjs')
const pnpxBinLocation = path.join(__dirname, '../../bin/pnpx.cjs')
export const binDir = path.join(__dirname, '../..', isWindows() ? 'dist' : 'bin')
export const pnpmBinLocation = path.join(binDir, 'pnpm.cjs')
export const pnpxBinLocation = path.join(__dirname, '../../bin/pnpx.cjs')
// The default timeout for tests is 4 minutes. Set a timeout for execPnpm calls
// for 3 minutes to make it more clear what specific part of a test is timing
@@ -93,12 +93,15 @@ export interface ChildProcess {
export function execPnpmSync (
args: string[],
opts?: {
env: Record<string, string>
cwd?: string
env?: Record<string, string>
expectSuccess?: boolean // similar to expect(status).toBe(0), but also prints error messages, which makes it easier to debug failed tests
stdio?: StdioOptions
timeout?: number
}
): ChildProcess {
const execResult = crossSpawn.sync(process.execPath, [pnpmBinLocation, ...args], {
cwd: opts?.cwd,
env: {
...createEnv(),
...opts?.env,
@@ -108,6 +111,14 @@ export function execPnpmSync (
})
if (execResult.error) throw execResult.error
if (execResult.signal) throw new Error(`Process terminated with signal ${execResult.signal}`)
if (execResult.status !== 0 && opts?.expectSuccess) {
const stdout = execResult.stdout?.toString().split('\n').map(line => `\t${line}`).join('\n')
const stderr = execResult.stderr?.toString().split('\n').map(line => `\t${line}`).join('\n')
let message = `Process exits with code ${execResult.status}`
if (stdout.trim()) message += `\nSTDOUT:\n${stdout}`
if (stderr.trim()) message += `\nSTDOUT:\n${stderr}`
throw new Error(message)
}
return execResult as ChildProcess
}

View File

@@ -1,9 +1,12 @@
import { add as addDistTag } from './distTags'
import {
binDir,
execPnpm,
execPnpmSync,
execPnpx,
execPnpxSync,
pnpmBinLocation,
pnpxBinLocation,
spawnPnpm,
spawnPnpx,
} from './execPnpm'
@@ -14,10 +17,13 @@ export { retryLoadJsonFile } from './retryLoadJsonFile'
export {
pathToLocalPkg,
testDefaults,
binDir,
execPnpm,
execPnpmSync,
execPnpx,
execPnpxSync,
pnpmBinLocation,
pnpxBinLocation,
spawnPnpm,
spawnPnpx,
addDistTag,

View File

@@ -0,0 +1,344 @@
import fs from 'fs'
import path from 'path'
import { prepare, preparePackages } from '@pnpm/prepare'
import { type ProjectManifest } from '@pnpm/types'
import { loadWorkspaceState } from '@pnpm/workspace.state'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpm, execPnpmSync } from '../utils'
const CONFIG = ['--config.verify-deps-before-run=true'] as const
test('single package workspace', async () => {
const manifest: ProjectManifest = {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': '100.0.0',
},
}
const project = prepare(manifest)
const EXEC = ['exec', 'echo', 'hello from exec'] as const
// attempting to execute a command without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND')
}
await execPnpm([...CONFIG, 'install'])
// installing dependencies on a single package workspace should not create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toBeUndefined()
}
// should be able to execute a command after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from exec')
}
project.writePackageJson(manifest)
// should be able to execute a command after the mtime of the manifest change but the content doesn't
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from exec')
}
project.writePackageJson({
...manifest,
dependencies: {
...manifest.dependencies,
'@pnpm.e2e/foo': '100.1.0',
},
})
// attempting to execute a command with outdated dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
}
await execPnpm([...CONFIG, 'install'])
// should be able to execute a command after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from exec')
}
project.writePackageJson({
...manifest,
dependencies: {}, // delete all dependencies
})
// attempting to execute a command with redundant dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
}
await execPnpm([...CONFIG, 'install'])
// should be able to execute a command without dependencies
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from exec')
}
// should set env.pnpm_run_skip_deps_check for the script
await execPnpm([...CONFIG, 'exec', 'node', '--eval', 'assert.strictEqual(process.env.pnpm_run_skip_deps_check, "true")'])
})
test('multi-project workspace', async () => {
const manifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
},
foo: {
name: 'foo',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
},
bar: {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
},
}
const projects = preparePackages([
{
location: '.',
package: manifests.root,
},
manifests.foo,
manifests.bar,
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
const EXEC = ['exec', 'node', '--print', '"hello from exec: " + process.cwd()'] as const
// attempting to execute a command in root without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a command in a workspace package without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC], {
cwd: projects.foo.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a command recursively without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--recursive', ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a command with filter without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--filter=foo', ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
await execPnpm([...CONFIG, 'install'])
// pnpm install should create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toStrictEqual({
catalogs: {},
lastValidatedTimestamp: expect.any(Number),
projectRootDirs: [
path.resolve('.'),
path.resolve('foo'),
path.resolve('bar'),
].sort(),
})
}
// should be able to execute a command in root after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${process.cwd()}`)
}
// should be able to execute a command in a workspace package after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], {
cwd: projects.foo.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('foo')}`)
}
// should be able to execute a command recursively after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, '--recursive', ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('foo')}`)
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('bar')}`)
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('.')}`)
}
// should be able to execute a command with filter after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, '--filter=foo', ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('foo')}`)
}
projects.foo.writePackageJson(manifests.foo)
// if the mtime of one manifest file changes but its content doesn't, pnpm run should update the packages list then run the script normally
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${process.cwd()}`)
}
// should skip check after pnpm has updated the packages list
{
const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${process.cwd()}`)
}
projects.foo.writePackageJson({
...manifests.foo,
dependencies: {
...manifests.foo.dependencies,
'@pnpm.e2e/foo': '=100.1.0',
},
} as ProjectManifest)
// attempting to execute a command in root without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
// attempting to execute a command in any workspace package without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC], {
cwd: projects.foo.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC], {
cwd: projects.bar.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
// attempting to execute a command recursively without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--recursive', ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
// attempting to execute a command with filter without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--filter=foo', ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
await execPnpm([...CONFIG, 'install'])
// should be able to execute a command in root after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${process.cwd()}`)
}
// should be able to execute a command in any workspace package after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], {
cwd: projects.foo.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('foo')}`)
}
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], {
cwd: projects.bar.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('bar')}`)
}
// should be able to execute a command recursively after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, '--recursive', ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('foo')}`)
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('bar')}`)
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('.')}`)
}
// should be able to execute a command with filter after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, '--filter=foo', ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${path.resolve('foo')}`)
}
manifests.baz = {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
}
fs.mkdirSync(path.resolve('baz'), { recursive: true })
fs.writeFileSync(path.resolve('baz/package.json'), JSON.stringify(manifests.baz, undefined, 2) + '\n')
// attempting to execute a command without updating projects list should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, ...EXEC])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_WORKSPACE_STRUCTURE_CHANGED')
}
await execPnpm([...CONFIG, 'install'])
// pnpm install should update the packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toStrictEqual({
catalogs: {},
lastValidatedTimestamp: expect.any(Number),
projectRootDirs: [
path.resolve('.'),
path.resolve('foo'),
path.resolve('bar'),
path.resolve('baz'),
].sort(),
})
}
// should be able to execute a command after projects list have been updated
{
const { stdout } = execPnpmSync([...CONFIG, ...EXEC], { expectSuccess: true })
expect(stdout.toString()).toContain(`hello from exec: ${process.cwd()}`)
}
// should set env.pnpm_run_skip_deps_check for all the scripts
await execPnpm([...CONFIG, 'exec', 'node', '--eval', 'assert.strictEqual(process.env.pnpm_run_skip_deps_check, "true")'])
})

View File

@@ -0,0 +1,959 @@
import fs from 'fs'
import path from 'path'
import { preparePackages } from '@pnpm/prepare'
import { type ProjectManifest } from '@pnpm/types'
import { loadWorkspaceState } from '@pnpm/workspace.state'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpm, execPnpmSync, pnpmBinLocation } from '../utils'
const CONFIG = ['--config.verify-deps-before-run=true'] as const
test('single dependency', async () => {
const checkEnv = 'node --eval "assert.strictEqual(process.env.pnpm_run_skip_deps_check, \'true\')"'
const manifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from root',
checkEnv,
},
},
foo: {
name: 'foo',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from foo',
checkEnv,
},
},
bar: {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from bar',
checkEnv,
},
},
}
const projects = preparePackages([
{
location: '.',
package: manifests.root,
},
manifests.foo,
manifests.bar,
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
// attempting to execute a script in root without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a script in a workspace package without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'], {
cwd: projects.foo.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a script recursively without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a script with filter without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--filter=foo', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
await execPnpm([...CONFIG, 'install'])
// pnpm install should create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toStrictEqual({
catalogs: {},
lastValidatedTimestamp: expect.any(Number),
projectRootDirs: [
path.resolve('.'),
path.resolve('foo'),
path.resolve('bar'),
].sort(),
})
}
// should be able to execute a script in root after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.')
expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.')
}
// should be able to execute a script in a workspace package after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], {
cwd: projects.foo.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain('hello from foo')
}
// should be able to execute a script recursively after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
expect(stdout.toString()).toContain('hello from bar')
}
// should be able to execute a script with filter after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, '--filter=foo', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
}
projects.foo.writePackageJson(manifests.foo)
// if the mtime of one manifest file changes but its content doesn't, pnpm run should update the packages list then run the script normally
{
const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
expect(stdout.toString()).not.toContain('No manifest files were modified since the last validation. Exiting check.')
expect(stdout.toString()).toContain('Some manifest files were modified since the last validation. Continuing check.')
expect(stdout.toString()).toContain('updating workspace state')
}
// should skip check after pnpm has updated the packages list
{
const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.')
expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.')
expect(stdout.toString()).not.toContain('updating workspace state')
}
projects.foo.writePackageJson({
...manifests.foo,
dependencies: {
...manifests.foo.dependencies,
'@pnpm.e2e/foo': '=100.1.0',
},
} as ProjectManifest)
// attempting to execute a script in root without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
expect(stdout.toString()).not.toContain('No manifest files were modified since the last validation. Exiting check.')
expect(stdout.toString()).toContain('Some manifest files were modified since the last validation. Continuing check.')
}
// attempting to execute a script in any workspace package without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'], {
cwd: projects.foo.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'], {
cwd: projects.bar.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
// attempting to execute a script recursively without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
// attempting to execute a script with filter without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--filter=foo', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain('project of id foo')
}
await execPnpm([...CONFIG, 'install'])
// should be able to execute a script in root after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.')
expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.')
}
// should be able to execute a script in any workspace package after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], {
cwd: projects.foo.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain('hello from foo')
}
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], {
cwd: projects.bar.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain('hello from bar')
}
// should be able to execute a script recursively after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
expect(stdout.toString()).toContain('hello from bar')
}
// should be able to execute a script with filter after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, '--filter=foo', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
}
manifests.baz = {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from baz',
checkEnv,
},
}
fs.mkdirSync(path.resolve('baz'), { recursive: true })
fs.writeFileSync(path.resolve('baz/package.json'), JSON.stringify(manifests.baz, undefined, 2) + '\n')
// attempting to execute a script without updating projects list should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_WORKSPACE_STRUCTURE_CHANGED')
}
await execPnpm([...CONFIG, 'install'])
// pnpm install should update the packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toStrictEqual({
catalogs: {},
lastValidatedTimestamp: expect.any(Number),
projectRootDirs: [
path.resolve('.'),
path.resolve('foo'),
path.resolve('bar'),
path.resolve('baz'),
].sort(),
})
}
// should be able to execute a script after projects list have been updated
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
}
// should set env.pnpm_run_skip_deps_check for all the scripts
await execPnpm([...CONFIG, '--recursive', 'run', 'checkEnv'])
})
test('multiple lockfiles', async () => {
const manifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from root',
},
},
foo: {
name: 'foo',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from foo',
},
},
bar: {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from bar',
},
},
}
const projects = preparePackages([
{
location: '.',
package: manifests.root,
},
manifests.foo,
manifests.bar,
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
const config = [
...CONFIG,
'--config.shared-workspace-lockfile=false',
]
// attempting to execute a script in root without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...config, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a script in a workspace package without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...config, 'start'], {
cwd: projects.foo.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a script recursively without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...config, '--recursive', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
// attempting to execute a script with filter without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...config, '--filter=foo', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
await execPnpm([...config, 'install'])
// pnpm install should create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toStrictEqual({
catalogs: {},
lastValidatedTimestamp: expect.any(Number),
projectRootDirs: [
path.resolve('.'),
path.resolve('foo'),
path.resolve('bar'),
].sort(),
})
}
// should be able to execute a script in root after dependencies have been installed
{
const { stdout } = execPnpmSync([...config, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.')
expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.')
}
// should be able to execute a script in a workspace package after dependencies have been installed
{
const { stdout } = execPnpmSync([...config, 'start'], {
cwd: projects.foo.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain('hello from foo')
}
// should be able to execute a script recursively after dependencies have been installed
{
const { stdout } = execPnpmSync([...config, '--recursive', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
expect(stdout.toString()).toContain('hello from bar')
}
// should be able to execute a script with filter after dependencies have been installed
{
const { stdout } = execPnpmSync([...config, '--filter=foo', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
}
projects.foo.writePackageJson(manifests.foo)
// if the mtime of one manifest file changes but its content doesn't, pnpm run should update the packages list then run the script normally
{
const { stdout } = execPnpmSync([...config, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
expect(stdout.toString()).not.toContain('No manifest files were modified since the last validation. Exiting check.')
expect(stdout.toString()).toContain('Some manifest files were modified since the last validation. Continuing check.')
expect(stdout.toString()).toContain('updating workspace state')
}
// should skip check after pnpm has updated the packages list
{
const { stdout } = execPnpmSync([...config, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
expect(stdout.toString()).toContain('No manifest files were modified since the last validation. Exiting check.')
expect(stdout.toString()).not.toContain('Some manifest files were modified since the last validation. Continuing check.')
expect(stdout.toString()).not.toContain('updating workspace state')
}
projects.foo.writePackageJson({
...manifests.foo,
dependencies: {
...manifests.foo.dependencies,
'@pnpm.e2e/foo': '=100.1.0',
},
} as ProjectManifest)
// attempting to execute a script in root without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...config, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain(`The lockfile in ${path.resolve('foo')} does not satisfy project of id .`)
}
// attempting to execute a script in any workspace package without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...config, 'start'], {
cwd: projects.foo.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain(`The lockfile in ${path.resolve('foo')} does not satisfy project of id .`)
}
{
const { status, stdout } = execPnpmSync([...config, 'start'], {
cwd: projects.bar.dir(),
})
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain(`The lockfile in ${path.resolve('foo')} does not satisfy project of id .`)
}
// attempting to execute a script recursively without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...config, '--recursive', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain(`The lockfile in ${path.resolve('foo')} does not satisfy project of id .`)
}
// attempting to execute a script with filter without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...config, '--filter=foo', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).toContain(`The lockfile in ${path.resolve('foo')} does not satisfy project of id .`)
}
await execPnpm([...config, 'install'])
// should be able to execute a script in root after dependencies have been updated
{
const { stdout } = execPnpmSync([...config, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
}
// should be able to execute a script in any workspace package after dependencies have been updated
{
const { stdout } = execPnpmSync([...config, 'start'], {
cwd: projects.foo.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain('hello from foo')
}
{
const { stdout } = execPnpmSync([...config, 'start'], {
cwd: projects.bar.dir(),
expectSuccess: true,
})
expect(stdout.toString()).toContain('hello from bar')
}
// should be able to execute a script recursively after dependencies have been updated
{
const { stdout } = execPnpmSync([...config, '--recursive', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
expect(stdout.toString()).toContain('hello from bar')
}
// should be able to execute a script with filter after dependencies have been updated
{
const { stdout } = execPnpmSync([...config, '--filter=foo', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
}
manifests.baz = {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from baz',
},
}
fs.mkdirSync(path.resolve('baz'), { recursive: true })
fs.writeFileSync(path.resolve('baz/package.json'), JSON.stringify(manifests.baz, undefined, 2) + '\n')
// attempting to execute a script without updating projects list should fail
{
const { status, stdout } = execPnpmSync([...config, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_WORKSPACE_STRUCTURE_CHANGED')
}
await execPnpm([...config, 'install'])
// pnpm install should update the packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toStrictEqual({
catalogs: {},
lastValidatedTimestamp: expect.any(Number),
projectRootDirs: [
path.resolve('.'),
path.resolve('foo'),
path.resolve('bar'),
path.resolve('baz'),
].sort(),
})
}
// should be able to execute a script after projects list have been updated
{
const { stdout } = execPnpmSync([...config, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
}
})
test('filtered install', async () => {
const manifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from root',
},
},
foo: {
name: 'foo',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from foo',
},
},
bar: {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from bar',
},
},
}
const projects = preparePackages([
{
location: '.',
package: manifests.root,
},
manifests.foo,
manifests.bar,
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
// attempting to execute a script without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--filter=foo', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
await execPnpm([...CONFIG, '--filter=foo', 'install'])
// should be able to execute a script after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, '--filter=foo', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
}
manifests.foo.dependencies!['@pnpm.e2e/foo'] = '=100.1.0'
projects.foo.writePackageJson(manifests.foo)
// attempt to execute a script without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--filter=foo', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
}
await execPnpm([...CONFIG, '--filter=foo', 'install'])
// should be able to execute a script after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, '--filter=foo', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from foo')
}
})
test('no dependencies', async () => {
const manifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
private: true,
scripts: {
start: 'echo hello from root',
},
},
foo: {
name: 'foo',
private: true,
scripts: {
start: 'echo hello from foo',
},
},
bar: {
name: 'bar',
private: true,
scripts: {
start: 'echo hello from bar',
},
},
}
preparePackages([
{
location: '.',
package: manifests.root,
},
manifests.foo,
manifests.bar,
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
// attempting to execute a script without `pnpm install` should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
await execPnpm([...CONFIG, 'install'])
// pnpm install should create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toStrictEqual({
catalogs: {},
lastValidatedTimestamp: expect.any(Number),
projectRootDirs: [
path.resolve('.'),
path.resolve('foo'),
path.resolve('bar'),
].sort(),
})
}
// should be able to execute a script after `pnpm install`
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
}
})
test('nested `pnpm run` should not check for mutated manifest', async () => {
const manifests: Record<string, ProjectManifest> = {
foo: {
name: 'foo',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
nestedScript: 'echo hello from nested script of foo',
},
},
bar: {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
nestedScript: 'echo hello from nested script of bar',
},
},
}
const projects = preparePackages([
manifests.foo,
manifests.bar,
])
for (const name in projects) {
const scriptPath = path.join(projects[name].dir(), 'mutate-manifest.js')
fs.writeFileSync(scriptPath, `
const fs = require('fs')
const manifest = require('./package.json')
manifest.dependencies['@pnpm.e2e/foo'] = '100.1.0'
const jsonText = JSON.stringify(manifest, undefined, 2)
fs.writeFileSync(require.resolve('./package.json'), jsonText)
console.log('manifest mutated: ${name}')
`)
}
// add to every manifest file a script named `start` which would inherit `config` and invoke `nestedScript`
for (const name in projects) {
manifests[name].scripts!.start =
`node mutate-manifest.js && node ${pnpmBinLocation} ${CONFIG.join(' ')} run nestedScript`
projects[name].writePackageJson(manifests[name])
}
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
// attempting to execute a script without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
await execPnpm([...CONFIG, 'install'])
// mutating the manifest should not cause nested `pnpm run nestedScript` to fail
{
const { stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('manifest mutated: foo')
expect(stdout.toString()).toContain('hello from nested script of foo')
expect(stdout.toString()).toContain('manifest mutated: bar')
expect(stdout.toString()).toContain('hello from nested script of bar')
}
// non nested script (`start`) should still fail (after `nestedScript` modified the manifests)
{
const { status, stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
}
await execPnpm([...CONFIG, 'install'])
// it shouldn't fail after the dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('manifest mutated: foo')
expect(stdout.toString()).toContain('hello from nested script of foo')
expect(stdout.toString()).toContain('manifest mutated: bar')
expect(stdout.toString()).toContain('hello from nested script of bar')
}
// it shouldn't fail after the manifests have been rewritten with the same content (by `nestedScript`)
{
const { stdout } = execPnpmSync([...CONFIG, '--recursive', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('manifest mutated: foo')
expect(stdout.toString()).toContain('hello from nested script of foo')
expect(stdout.toString()).toContain('manifest mutated: bar')
expect(stdout.toString()).toContain('hello from nested script of bar')
}
})
test('should check for outdated catalogs', async () => {
const manifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
scripts: {
start: 'echo hello from root',
},
},
foo: {
name: 'foo',
private: true,
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
scripts: {
start: 'echo hello from foo',
},
},
bar: {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
scripts: {
start: 'echo hello from bar',
},
},
}
preparePackages([
{
location: '.',
package: manifests.root,
},
manifests.foo,
manifests.bar,
])
const workspaceManifest = {
catalog: {
'@pnpm.e2e/foo': '=100.0.0',
} as Record<string, string>,
packages: ['**', '!store/**'],
}
writeYamlFile('pnpm-workspace.yaml', workspaceManifest)
// attempting to execute a script without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_CACHE')
}
await execPnpm([...CONFIG, 'install'])
// pnpm install should create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toStrictEqual({
catalogs: {
default: workspaceManifest.catalog,
},
lastValidatedTimestamp: expect.any(Number),
projectRootDirs: [
path.resolve('.'),
path.resolve('foo'),
path.resolve('bar'),
].sort(),
})
}
// should be able to execute a script after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
}
workspaceManifest.catalog.foo = '=100.1.0'
writeYamlFile('pnpm-workspace.yaml', workspaceManifest)
// attempting to execute a script without updating dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_OUTDATED')
}
await execPnpm([...CONFIG, 'install'])
// should be able to execute a script after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
}
})
test('failed to install dependencies', async () => {
const manifests: Record<string, ProjectManifest> = {
root: {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from root',
},
},
foo: {
name: 'foo',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from foo',
},
},
bar: {
name: 'bar',
private: true,
dependencies: {
'@pnpm.e2e/foo': '=100.0.0',
},
scripts: {
start: 'echo hello from bar',
},
},
}
const projects = preparePackages([
{
location: '.',
package: manifests.root,
},
manifests.foo,
manifests.bar,
])
writeYamlFile('pnpm-workspace.yaml', { packages: ['**', '!store/**'] })
await execPnpm([...CONFIG, 'install'])
// should be able to execute a script after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from root')
}
// modify a manifest file to require an impossible version
manifests.foo.dependencies!['@pnpm.e2e/foo'] = '=9999.9999.9999' // this version does not exist
projects.foo.writePackageJson(manifests.foo)
// should fail to install dependencies
{
const { status, stdout } = execPnpmSync([...CONFIG, 'install'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_NO_MATCHING_VERSION')
}
// attempting to execute a script without successfully updating the dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
}
})

View File

@@ -0,0 +1,257 @@
import fs from 'fs'
import path from 'path'
import { prepare } from '@pnpm/prepare'
import { type ProjectManifest } from '@pnpm/types'
import { loadWorkspaceState } from '@pnpm/workspace.state'
import { execPnpm, execPnpmSync, pnpmBinLocation } from '../utils'
const CONFIG = ['--config.verify-deps-before-run=true'] as const
test('single dependency', async () => {
const manifest: ProjectManifest = {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': '100.0.0',
},
scripts: {
start: 'echo hello from script',
checkEnv: 'node --eval "assert.strictEqual(process.env.pnpm_run_skip_deps_check, \'true\')"',
},
}
const project = prepare(manifest)
// attempting to execute a script without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND')
}
await execPnpm([...CONFIG, 'install'])
// installing dependencies on a single package workspace should not create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toBeUndefined()
}
// should be able to execute a script after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from script')
}
project.writePackageJson(manifest)
// should be able to execute a script after the mtime of the manifest change but the content doesn't
{
const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from script')
expect(stdout.toString()).not.toContain('The manifest file is not newer than the lockfile. Exiting check.')
expect(stdout.toString()).toContain('The manifest is newer than the lockfile. Continuing check.')
}
project.writePackageJson({
...manifest,
dependencies: {
...manifest.dependencies,
'@pnpm.e2e/foo': '100.1.0',
},
})
// attempting to execute a script with outdated dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
expect(stdout.toString()).not.toContain('The manifest file is not newer than the lockfile. Exiting check.')
expect(stdout.toString()).toContain('The manifest is newer than the lockfile. Continuing check.')
}
await execPnpm([...CONFIG, 'install'])
// should be able to execute a script after dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, '--reporter=ndjson', 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from script')
expect(stdout.toString()).toContain('The manifest file is not newer than the lockfile. Exiting check.')
expect(stdout.toString()).not.toContain('The manifest is newer than the lockfile. Continuing check.')
}
project.writePackageJson({
...manifest,
dependencies: {}, // delete all dependencies
})
// attempting to execute a script with redundant dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
}
await execPnpm([...CONFIG, 'install'])
// should be able to execute a script without dependencies
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from script')
}
// should set env.pnpm_run_skip_deps_check for the script
await execPnpm([...CONFIG, 'run', 'checkEnv'])
})
test('deleting node_modules after install', async () => {
const manifest: ProjectManifest = {
name: 'root',
private: true,
dependencies: {
'@pnpm.e2e/foo': '100.0.0',
},
scripts: {
start: 'echo hello from script',
},
}
prepare(manifest)
// attempting to execute a script without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND')
}
await execPnpm([...CONFIG, 'install'])
// installing dependencies on a single package workspace should not create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toBeUndefined()
}
// should be able to execute a script after dependencies have been installed
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from script')
}
fs.rmSync('node_modules', { recursive: true })
// attempting to execute a script after node_modules has been deleted should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_NO_DEPS')
}
})
test('no dependencies', async () => {
const manifest: ProjectManifest = {
name: 'root',
private: true,
scripts: {
start: 'echo hello from script',
},
}
prepare(manifest)
// attempting to execute a script without the lockfile should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND')
}
await execPnpm([...CONFIG, 'install'])
// installing dependencies on a single package workspace should not create a packages list cache
{
const workspaceState = loadWorkspaceState(process.cwd())
expect(workspaceState).toBeUndefined()
}
// should be able to execute a script after the lockfile has been created
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('hello from script')
}
})
test('nested `pnpm run` should not check for mutated manifest', async () => {
const manifest: ProjectManifest = {
name: 'root',
private: true,
scripts: {
nestedScript: 'echo hello from the nested script',
},
dependencies: {
'@pnpm.e2e/foo': '100.0.0',
},
}
const project = prepare(manifest)
fs.writeFileSync('mutate-manifest.js', `
const fs = require('fs')
const manifest = require('./package.json')
manifest.dependencies['@pnpm.e2e/foo'] = '100.1.0'
const jsonText = JSON.stringify(manifest, undefined, 2)
fs.writeFileSync(require.resolve('./package.json'), jsonText)
console.log('manifest mutated')
`)
const cacheDir = path.resolve('cache')
const config = [
CONFIG,
`--config.cache-dir=${cacheDir}`,
]
// add a script named `start` which would inherit `config` and invoke `nestedScript`
manifest.scripts!.start =
`node mutate-manifest.js && node ${pnpmBinLocation} ${config.join(' ')} run nestedScript`
project.writePackageJson(manifest)
// attempting to execute a script without installing dependencies should fail
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND')
}
await execPnpm([...CONFIG, 'install'])
// mutating the manifest should not cause nested `pnpm run nestedScript` to fail
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('manifest mutated')
expect(stdout.toString()).toContain('hello from the nested script')
}
// non nested script (`start`) should still fail (after `nestedScript` modified the manifest)
{
const { status, stdout } = execPnpmSync([...CONFIG, 'start'])
expect(status).not.toBe(0)
expect(stdout.toString()).toContain('ERR_PNPM_RUN_CHECK_DEPS_UNSATISFIED_PKG_MANIFEST')
}
await execPnpm([...CONFIG, 'install'])
// it shouldn't fail after the dependencies have been updated
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('manifest mutated')
expect(stdout.toString()).toContain('hello from the nested script')
}
// it shouldn't fail after the manifest has been rewritten with the same content (by `nestedScript`)
{
const { stdout } = execPnpmSync([...CONFIG, 'start'], { expectSuccess: true })
expect(stdout.toString()).toContain('manifest mutated')
expect(stdout.toString()).toContain('hello from the nested script')
}
})

View File

@@ -176,6 +176,9 @@
},
{
"path": "../workspace/pkgs-graph"
},
{
"path": "../workspace/state"
}
]
}

17
workspace/state/README.md Normal file
View File

@@ -0,0 +1,17 @@
# @pnpm/workspace.state
> Track the list of actual paths of workspace packages in a cache.
<!--@shields('npm')-->
[![npm version](https://img.shields.io/npm/v/@pnpm/workspace.state.svg)](https://www.npmjs.com/package/@pnpm/workspace.state)
<!--/@-->
## Installation
```sh
pnpm add @pnpm/workspace.state
```
## License
MIT

View File

@@ -0,0 +1,49 @@
{
"name": "@pnpm/workspace.state",
"version": "0.0.0",
"description": "Track the list of actual paths of workspace packages in a cache",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"engines": {
"node": ">=18.12"
},
"files": [
"lib",
"!*.map"
],
"scripts": {
"test": "pnpm run compile && pnpm run _test",
"_test": "jest",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"prepublishOnly": "pnpm run compile",
"compile": "tsc --build && pnpm run lint --fix"
},
"repository": "https://github.com/pnpm/pnpm/blob/main/workspace/state",
"keywords": [
"pnpm10",
"pnpm",
"mtime"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/workspace/state#readme",
"funding": "https://opencollective.com/pnpm",
"dependencies": {
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/logger": "workspace:*",
"@pnpm/types": "workspace:*",
"ramda": "catalog:"
},
"devDependencies": {
"@pnpm/prepare": "workspace:*",
"@pnpm/workspace.state": "workspace:*"
},
"exports": {
".": "./lib/index.js"
},
"jest": {
"preset": "@pnpm/jest-config"
}
}

View File

@@ -0,0 +1,14 @@
import { type Catalogs } from '@pnpm/catalogs.types'
import { type WorkspaceState, type ProjectsList } from './types'
export interface CreateWorkspaceStateOptions {
allProjects: ProjectsList
catalogs: Catalogs | undefined
lastValidatedTimestamp: number
}
export const createWorkspaceState = (opts: CreateWorkspaceStateOptions): WorkspaceState => ({
catalogs: opts.catalogs,
lastValidatedTimestamp: opts.lastValidatedTimestamp,
projectRootDirs: opts.allProjects.map(project => project.rootDir).sort(),
})

View File

@@ -0,0 +1,4 @@
import path from 'path'
export const getFilePath = (workspaceDir: string): string =>
path.join(workspaceDir, 'node_modules', '.pnpm-workspace-state.json')

View File

@@ -0,0 +1,3 @@
export { loadWorkspaceState } from './loadWorkspaceState'
export { type UpdateWorkspaceStateOptions, updateWorkspaceState } from './updateWorkspaceState'
export { type WorkspaceState, type ProjectsList } from './types'

View File

@@ -0,0 +1,20 @@
import fs from 'fs'
import util from 'util'
import { logger } from '@pnpm/logger'
import { getFilePath } from './filePath'
import { type WorkspaceState } from './types'
export function loadWorkspaceState (workspaceDir: string): WorkspaceState | undefined {
logger.debug({ msg: 'loading workspace state' })
const cacheFile = getFilePath(workspaceDir)
let cacheFileContent: string
try {
cacheFileContent = fs.readFileSync(cacheFile, 'utf-8')
} catch (error) {
if (util.types.isNativeError(error) && 'code' in error && error.code === 'ENOENT') {
return undefined
}
throw error
}
return JSON.parse(cacheFileContent) as WorkspaceState
}

View File

@@ -0,0 +1,10 @@
import { type Catalogs } from '@pnpm/catalogs.types'
import { type Project, type ProjectRootDir } from '@pnpm/types'
export type ProjectsList = Array<Pick<Project, 'rootDir'>>
export interface WorkspaceState {
catalogs: Catalogs | undefined
lastValidatedTimestamp: number
projectRootDirs: ProjectRootDir[]
}

View File

@@ -0,0 +1,23 @@
import fs from 'fs'
import path from 'path'
import { type Catalogs } from '@pnpm/catalogs.types'
import { logger } from '@pnpm/logger'
import { getFilePath } from './filePath'
import { createWorkspaceState } from './createWorkspaceState'
import { type ProjectsList } from './types'
export interface UpdateWorkspaceStateOptions {
allProjects: ProjectsList
catalogs: Catalogs | undefined
lastValidatedTimestamp: number
workspaceDir: string
}
export async function updateWorkspaceState (opts: UpdateWorkspaceStateOptions): Promise<void> {
logger.debug({ msg: 'updating workspace state' })
const workspaceState = createWorkspaceState(opts)
const workspaceStateJSON = JSON.stringify(workspaceState, undefined, 2) + '\n'
const cacheFile = getFilePath(opts.workspaceDir)
await fs.promises.mkdir(path.dirname(cacheFile), { recursive: true })
await fs.promises.writeFile(cacheFile, workspaceStateJSON)
}

View File

@@ -0,0 +1,59 @@
import path from 'path'
import { prepareEmpty, preparePackages } from '@pnpm/prepare'
import { type ProjectRootDir } from '@pnpm/types'
import { createWorkspaceState } from '../src/createWorkspaceState'
const lastValidatedTimestamp = Date.now()
test('createWorkspaceState() on empty list', () => {
prepareEmpty()
expect(
createWorkspaceState({
allProjects: [],
catalogs: undefined,
lastValidatedTimestamp,
})
).toStrictEqual({
catalogs: undefined,
lastValidatedTimestamp,
projectRootDirs: [],
})
})
test('createWorkspaceState() on non-empty list', () => {
preparePackages(['a', 'b', 'c', 'd'].map(name => ({
location: `./packages/${name}`,
package: { name },
})))
expect(
createWorkspaceState({
allProjects: [
{ rootDir: path.resolve('packages/c') as ProjectRootDir },
{ rootDir: path.resolve('packages/b') as ProjectRootDir },
{ rootDir: path.resolve('packages/a') as ProjectRootDir },
{ rootDir: path.resolve('packages/d') as ProjectRootDir },
],
lastValidatedTimestamp,
catalogs: {
default: {
foo: '0.1.2',
},
},
})
).toStrictEqual({
catalogs: {
default: {
foo: '0.1.2',
},
},
lastValidatedTimestamp,
projectRootDirs: [
path.resolve('packages/a'),
path.resolve('packages/b'),
path.resolve('packages/c'),
path.resolve('packages/d'),
],
})
})

View File

@@ -0,0 +1,12 @@
import path from 'path'
import { prepareEmpty } from '@pnpm/prepare'
import { getFilePath } from '../src/filePath'
test('getFilePath()', () => {
prepareEmpty()
expect(
getFilePath(process.cwd())
).toStrictEqual(
path.resolve(path.resolve('node_modules/.pnpm-workspace-state.json'))
)
})

View File

@@ -0,0 +1,60 @@
import path from 'path'
import fs from 'fs'
import { logger } from '@pnpm/logger'
import { type ProjectRootDir } from '@pnpm/types'
import { prepareEmpty } from '@pnpm/prepare'
import { getFilePath } from '../src/filePath'
import { type WorkspaceState, loadWorkspaceState } from '../src/index'
const lastValidatedTimestamp = Date.now()
const originalLoggerDebug = logger.debug
beforeEach(() => {
logger.debug = jest.fn(originalLoggerDebug)
})
afterEach(() => {
logger.debug = originalLoggerDebug
})
const expectedLoggerCalls = [[{ msg: 'loading workspace state' }]]
test('loadWorkspaceState() when cache dir does not exist', async () => {
prepareEmpty()
const workspaceDir = process.cwd()
expect(loadWorkspaceState(workspaceDir)).toBeUndefined()
expect((logger.debug as jest.Mock).mock.calls).toStrictEqual(expectedLoggerCalls)
})
test('loadWorkspaceState() when cache dir exists but not the file', async () => {
prepareEmpty()
const workspaceDir = process.cwd()
const cacheFile = getFilePath(workspaceDir)
fs.mkdirSync(path.dirname(cacheFile), { recursive: true })
expect(loadWorkspaceState(workspaceDir)).toBeUndefined()
expect((logger.debug as jest.Mock).mock.calls).toStrictEqual(expectedLoggerCalls)
})
test('loadWorkspaceState() when cache file exists and is correct', async () => {
prepareEmpty()
const workspaceDir = process.cwd()
const cacheFile = getFilePath(workspaceDir)
fs.mkdirSync(path.dirname(cacheFile), { recursive: true })
const workspaceState: WorkspaceState = {
catalogs: {
default: {
foo: '0.1.2',
},
},
lastValidatedTimestamp,
projectRootDirs: [
path.resolve('packages/a') as ProjectRootDir,
path.resolve('packages/b') as ProjectRootDir,
path.resolve('packages/c') as ProjectRootDir,
path.resolve('packages/d') as ProjectRootDir,
],
}
fs.writeFileSync(cacheFile, JSON.stringify(workspaceState))
expect(loadWorkspaceState(workspaceDir)).toStrictEqual(workspaceState)
expect((logger.debug as jest.Mock).mock.calls).toStrictEqual(expectedLoggerCalls)
})

View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../test.lib",
"rootDir": "."
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -0,0 +1,68 @@
import path from 'path'
import { logger } from '@pnpm/logger'
import { preparePackages } from '@pnpm/prepare'
import { type ProjectRootDir } from '@pnpm/types'
import { loadWorkspaceState, updateWorkspaceState } from '../src/index'
const lastValidatedTimestamp = Date.now()
const originalLoggerDebug = logger.debug
afterEach(() => {
logger.debug = originalLoggerDebug
})
test('updateWorkspaceState()', async () => {
preparePackages(['a', 'b', 'c', 'd'].map(name => ({
location: `./packages/${name}`,
package: { name },
})))
const workspaceDir = process.cwd()
expect(loadWorkspaceState(workspaceDir)).toBeUndefined()
logger.debug = jest.fn(originalLoggerDebug)
await updateWorkspaceState({
lastValidatedTimestamp,
workspaceDir,
catalogs: undefined,
allProjects: [],
})
expect((logger.debug as jest.Mock).mock.calls).toStrictEqual([[{ msg: 'updating workspace state' }]])
expect(loadWorkspaceState(workspaceDir)).toStrictEqual({
lastValidatedTimestamp,
projectRootDirs: [],
})
logger.debug = jest.fn(originalLoggerDebug)
await updateWorkspaceState({
lastValidatedTimestamp,
workspaceDir,
catalogs: {
default: {
foo: '0.1.2',
},
},
allProjects: [
{ rootDir: path.resolve('packages/c') as ProjectRootDir },
{ rootDir: path.resolve('packages/a') as ProjectRootDir },
{ rootDir: path.resolve('packages/d') as ProjectRootDir },
{ rootDir: path.resolve('packages/b') as ProjectRootDir },
],
})
expect((logger.debug as jest.Mock).mock.calls).toStrictEqual([[{ msg: 'updating workspace state' }]])
expect(loadWorkspaceState(workspaceDir)).toStrictEqual({
catalogs: {
default: {
foo: '0.1.2',
},
},
lastValidatedTimestamp,
projectRootDirs: [
path.resolve('packages/a'),
path.resolve('packages/b'),
path.resolve('packages/c'),
path.resolve('packages/d'),
],
})
})

View File

@@ -0,0 +1,25 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../__utils__/prepare"
},
{
"path": "../../catalogs/types"
},
{
"path": "../../packages/logger"
},
{
"path": "../../packages/types"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}