feat: catalogs (#8122)

* feat: create new @pnpm/catalogs.types package (#8026)

* feat: read catalog configs from workspace manifest (#8123)

* refactor: move InvalidWorkspaceManifestError to its own file

* feat: read catalogs config from workspace manifest

* chore: add changeset for new catalog config parsing

* feat: create new `@pnpm/catalogs.protocol-parser` package (#8124)

This works around a problem with pnpm's CI setup not compiling
packages that are not dependencies of the main pnpm package before
running these tests.

https://github.com/pnpm/pnpm/pull/8027#issuecomment-2081650304

* refactor: factor out isWantedDepPrefSame to extend in a future commit (#8125)

* feat: create new `@pnpm/catalogs.config` package (#8220)

* refactor: remove single default catalog check

This check will happen in  `@pnpm/catalogs.config` instead.

* feat: create new @pnpm/catalogs.config package

* fix: work around CI setup not compiling orphan packages before testing

This works around a problem with pnpm's CI setup not compiling
packages that are not dependencies of the main pnpm package before
running these tests.

https://github.com/pnpm/pnpm/pull/8027#issuecomment-2081650304

* feat: create new `@pnpm/catalogs.resolver` package (#8219)

* feat: create new @pnpm/catalogs.resolver package

* fix: work around CI setup not compiling orphan packages before testing

This works around a problem with pnpm's CI setup not compiling
packages that are not dependencies of the main pnpm package before
running these tests.

https://github.com/pnpm/pnpm/pull/8027#issuecomment-2081650304

* feat: implement catalog protocol for publish (#8225)

* feat: implement catalog protocol for install (#8221)

* feat: add catalogs to @pnpm/config

* refactor: factor out resolveDependenciesOfImporterDependency function

* feat: implement catalog resolver and replace prefs

* revert: work around CI setup not compiling orphan packages before testing

* feat: record catalog lookup snapshots through propagated metadata

* feat: update projects when catalogs config changes

* test: add catalog protocol install tests

* refactor: remove filter-packages-from-dir dependency from core tests (#8244)

* refactor: remove filter-packages-from-dir dependency from core tests

* test: refactor

* test: refactor

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Brandon Cheng
2024-06-27 08:19:38 -04:00
committed by GitHub
parent 3beb895afe
commit 9c63679df1
88 changed files with 1943 additions and 49 deletions

View File

@@ -0,0 +1,5 @@
---
"@pnpm/exportable-manifest": major
---
Creating an exportable manifest now requires a `Catalog` object to be passed to `createExportableManifest` in order to replace `catalog:` protocol specifiers.

View File

@@ -0,0 +1,5 @@
---
"@pnpm/workspace.read-manifest": minor
---
The `readWorkspaceManifest` function now parses and validates [pnpm catalogs](https://github.com/pnpm/rfcs/pull/1) configs if present.

View File

@@ -0,0 +1,5 @@
# @pnpm/catalogs.config
## 0.1.0
Initial release

View File

@@ -0,0 +1,3 @@
# @pnpm/catalogs.config
> Create a normalized catalogs config from `pnpm-workspace.yaml` contents.

View File

@@ -0,0 +1 @@
module.exports = require('../../jest.config.js')

View File

@@ -0,0 +1,44 @@
{
"name": "@pnpm/catalogs.config",
"version": "0.1.0",
"description": "Create a normalized catalogs config from pnpm-workspace.yaml contents.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"engines": {
"node": ">=18.12"
},
"files": [
"lib",
"!*.map"
],
"repository": "https://github.com/pnpm/pnpm/blob/main/catalogs/config",
"keywords": [
"pnpm9",
"pnpm",
"types"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/catalogs/config#readme",
"scripts": {
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"compile": "tsc --build && pnpm run lint --fix",
"prepublishOnly": "pnpm run compile",
"test": "pnpm run compile && pnpm run _test",
"_test": "jest"
},
"funding": "https://opencollective.com/pnpm",
"exports": {
".": "./lib/index.js"
},
"dependencies": {
"@pnpm/error": "workspace:*"
},
"devDependencies": {
"@pnpm/catalogs.config": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/workspace.read-manifest": "workspace:*"
}
}

View File

@@ -0,0 +1,36 @@
import { PnpmError } from '@pnpm/error'
import { type Catalogs } from '@pnpm/catalogs.types'
import { type WorkspaceManifest } from '@pnpm/workspace.read-manifest'
export function getCatalogsFromWorkspaceManifest (
workspaceManifest: Pick<WorkspaceManifest, 'catalog' | 'catalogs'> | undefined
): Catalogs {
// If the pnpm-workspace.yaml file doesn't exist, no catalogs are defined.
//
// In some cases, it makes sense for callers to handle null/undefined checks
// of this form. In this case, let's explicitly handle not found
// pnpm-workspace.yaml files by returning an empty catalog to make consuming
// logic easier.
if (workspaceManifest == null) {
return {}
}
checkDefaultCatalogIsDefinedOnce(workspaceManifest)
return {
// If workspaceManifest.catalog is undefined, intentionally allow the spread
// below to overwrite it. The check above ensures only one or the either is
// defined.
default: workspaceManifest.catalog,
...workspaceManifest.catalogs,
}
}
export function checkDefaultCatalogIsDefinedOnce (manifest: Pick<WorkspaceManifest, 'catalog' | 'catalogs'>): void {
if (manifest.catalog != null && manifest.catalogs?.default != null) {
throw new PnpmError(
'INVALID_CATALOGS_CONFIGURATION',
'The \'default\' catalog was defined multiple times. Use the \'catalog\' field or \'catalogs.default\', but not both.')
}
}

View File

@@ -0,0 +1 @@
export { getCatalogsFromWorkspaceManifest } from './getCatalogsFromWorkspaceManifest'

View File

@@ -0,0 +1,54 @@
import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config'
test('combines implicit default and named catalogs', () => {
expect(getCatalogsFromWorkspaceManifest({
catalog: {
foo: '^1.0.0',
},
catalogs: {
bar: {
baz: '^2.0.0',
},
},
})).toEqual({
default: {
foo: '^1.0.0',
},
bar: {
baz: '^2.0.0',
},
})
})
test('combines explicit default and named catalogs', () => {
expect(getCatalogsFromWorkspaceManifest({
catalogs: {
default: {
foo: '^1.0.0',
},
bar: {
baz: '^2.0.0',
},
},
})).toEqual({
default: {
foo: '^1.0.0',
},
bar: {
baz: '^2.0.0',
},
})
})
test('throws if default catalog is defined multiple times', () => {
expect(() => getCatalogsFromWorkspaceManifest({
catalog: {
bar: '^2.0.0',
},
catalogs: {
default: {
foo: '^1.0.0',
},
},
})).toThrow(/The 'default' catalog was defined multiple times/)
})

View File

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

View File

@@ -0,0 +1,23 @@
{
"extends": "@pnpm/tsconfig",
"composite": true,
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../packages/error"
},
{
"path": "../../workspace/read-manifest"
},
{
"path": "../types"
}
]
}

View File

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

View File

@@ -0,0 +1,5 @@
# @pnpm/catalogs.protocol-parser
## 0.1.0
Initial release

View File

@@ -0,0 +1,3 @@
# @pnpm/catalogs.protocol-parser
> Parse catalog protocol specifiers and return the catalog name.

View File

@@ -0,0 +1 @@
module.exports = require('../../jest.config.js')

View File

@@ -0,0 +1,39 @@
{
"name": "@pnpm/catalogs.protocol-parser",
"version": "0.1.0",
"description": "Parse catalog protocol specifiers and return the catalog name.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"engines": {
"node": ">=18.12"
},
"files": [
"lib",
"!*.map"
],
"repository": "https://github.com/pnpm/pnpm/blob/main/catalogs/protocol-parser",
"keywords": [
"pnpm9",
"pnpm",
"types"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/catalogs/protocol-parser#readme",
"scripts": {
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"compile": "tsc --build && pnpm run lint --fix",
"prepublishOnly": "pnpm run compile",
"test": "pnpm run compile && pnpm run _test",
"_test": "jest"
},
"funding": "https://opencollective.com/pnpm",
"exports": {
".": "./lib/index.js"
},
"devDependencies": {
"@pnpm/catalogs.protocol-parser": "workspace:*"
}
}

View File

@@ -0,0 +1 @@
export { parseCatalogProtocol } from './parseCatalogProtocol'

View File

@@ -0,0 +1,18 @@
const CATALOG_PROTOCOL = 'catalog:'
/**
* Parse a package.json dependency specifier using the catalog: protocol.
* Returns null if the given specifier does not start with 'catalog:'.
*/
export function parseCatalogProtocol (pref: string): string | 'default' | null {
if (!pref.startsWith(CATALOG_PROTOCOL)) {
return null
}
const catalogNameRaw = pref.slice(CATALOG_PROTOCOL.length).trim()
// Allow a specifier of 'catalog:' to be a short-hand for 'catalog:default'.
const catalogNameNormalized = catalogNameRaw === '' ? 'default' : catalogNameRaw
return catalogNameNormalized
}

View File

@@ -0,0 +1,20 @@
import { parseCatalogProtocol } from '@pnpm/catalogs.protocol-parser'
test('parses named catalog', () => {
expect(parseCatalogProtocol('catalog:foo')).toBe('foo')
expect(parseCatalogProtocol('catalog:bar')).toBe('bar')
})
test('returns null for specifier not using catalog protocol', () => {
expect(parseCatalogProtocol('^1.0.0')).toBe(null)
})
describe('default catalog', () => {
test('parses explicit default catalog', () => {
expect(parseCatalogProtocol('catalog:default')).toBe('default')
})
test('parses implicit catalog', () => {
expect(parseCatalogProtocol('catalog:')).toBe('default')
})
})

View File

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

View File

@@ -0,0 +1,13 @@
{
"extends": "@pnpm/tsconfig",
"composite": true,
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": []
}

View File

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

View File

@@ -0,0 +1,5 @@
# @pnpm/catalogs.resolver
## 0.1.0
Initial release

View File

@@ -0,0 +1,3 @@
# @pnpm/catalogs.resolver
> Dereferences `catalog:` protocol specifiers into usable specifiers.

View File

@@ -0,0 +1 @@
module.exports = require('../../jest.config.js')

View File

@@ -0,0 +1,44 @@
{
"name": "@pnpm/catalogs.resolver",
"version": "0.1.0",
"description": "Dereferences catalog protocol specifiers into usable specifiers.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"engines": {
"node": ">=18.12"
},
"files": [
"lib",
"!*.map"
],
"repository": "https://github.com/pnpm/pnpm/blob/main/catalogs/resolver",
"keywords": [
"pnpm9",
"pnpm",
"types"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/catalogs/resolver#readme",
"scripts": {
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"compile": "tsc --build && pnpm run lint --fix",
"prepublishOnly": "pnpm run compile",
"test": "pnpm run compile && pnpm run _test",
"_test": "jest"
},
"funding": "https://opencollective.com/pnpm",
"exports": {
".": "./lib/index.js"
},
"dependencies": {
"@pnpm/catalogs.protocol-parser": "workspace:^",
"@pnpm/error": "workspace:^"
},
"devDependencies": {
"@pnpm/catalogs.resolver": "workspace:*",
"@pnpm/catalogs.types": "workspace:*"
}
}

View File

@@ -0,0 +1,11 @@
export {
resolveFromCatalog,
type CatalogResolution,
type CatalogResolutionFound,
type CatalogResolutionMisconfiguration,
type CatalogResolutionUnused as CatalogResolutionNotUsed,
type CatalogResolutionResult,
type CatalogResolver,
type WantedDependency,
} from './resolveFromCatalog'
export { type CatalogResultMatcher, matchCatalogResolveResult } from './matchCatalogResolveResult'

View File

@@ -0,0 +1,18 @@
import { type CatalogResolutionUnused, type CatalogResolutionResult, type CatalogResolutionFound, type CatalogResolutionMisconfiguration } from './resolveFromCatalog'
export interface CatalogResultMatcher<T> {
readonly found: (found: CatalogResolutionFound) => T
readonly misconfiguration: (misconfiguration: CatalogResolutionMisconfiguration) => T
readonly unused: (unused: CatalogResolutionUnused) => T
}
export function matchCatalogResolveResult<T> (
result: CatalogResolutionResult,
matcher: CatalogResultMatcher<T>
): T {
switch (result.type) {
case 'found': return matcher.found(result)
case 'misconfiguration': return matcher.misconfiguration(result)
case 'unused': return matcher.unused(result)
}
}

View File

@@ -0,0 +1,95 @@
import { PnpmError } from '@pnpm/error'
import { parseCatalogProtocol } from '@pnpm/catalogs.protocol-parser'
import { type Catalogs } from '@pnpm/catalogs.types'
export interface WantedDependency {
readonly pref: string
readonly alias: string
}
/**
* Dereferences a wanted dependency using the catalog protocol and returns the
* configured version.
*
* Example: catalog:default -> ^1.2.3
*/
export type CatalogResolver = (wantedDependency: WantedDependency) => CatalogResolutionResult
export type CatalogResolutionResult = CatalogResolutionFound | CatalogResolutionMisconfiguration | CatalogResolutionUnused
export interface CatalogResolutionFound {
readonly type: 'found'
readonly resolution: CatalogResolution
}
export interface CatalogResolution {
/**
* The name of the catalog the resolved specifier was defined in.
*/
readonly catalogName: string
/**
* The specifier that should be used for the wanted dependency. This is a
* usable version that replaces the catalog protocol with the relevant user
* defined specifier.
*/
readonly specifier: string
}
/**
* The user misconfigured a catalog entry. The entry could be missing or
* invalid.
*/
export interface CatalogResolutionMisconfiguration {
readonly type: 'misconfiguration'
/**
* Convenience error to rethrow.
*/
readonly error: PnpmError
readonly catalogName: string
}
/**
* The wanted dependency does not use the catalog protocol.
*/
export interface CatalogResolutionUnused {
readonly type: 'unused'
}
export function resolveFromCatalog (catalogs: Catalogs, wantedDependency: WantedDependency): CatalogResolutionResult {
const catalogName = parseCatalogProtocol(wantedDependency.pref)
if (catalogName == null) {
return { type: 'unused' }
}
const catalogLookup = catalogs[catalogName]?.[wantedDependency.alias]
if (catalogLookup == null) {
return {
type: 'misconfiguration',
catalogName,
error: new PnpmError(
'CATALOG_ENTRY_NOT_FOUND_FOR_SPEC',
`No catalog entry '${wantedDependency.alias}' was found for catalog '${catalogName}'.`),
}
}
if (parseCatalogProtocol(catalogLookup) != null) {
return {
type: 'misconfiguration',
catalogName,
error: new PnpmError(
'CATALOG_ENTRY_INVALID_RECURSIVE_DEFINITION',
`Found invalid catalog entry using the catalog protocol recursively. The entry for '${wantedDependency.alias}' in catalog '${catalogName}' is invalid.`),
}
}
return {
type: 'found',
resolution: {
catalogName,
specifier: catalogLookup,
},
}
}

View File

@@ -0,0 +1,77 @@
import { type WantedDependency, resolveFromCatalog } from '@pnpm/catalogs.resolver'
import { type Catalogs } from '@pnpm/catalogs.types'
describe('default catalog', () => {
const catalogs = {
default: {
foo: '1.0.0',
},
}
test('resolves using implicit name', () => {
expect(resolveFromCatalog(catalogs, { alias: 'foo', pref: 'catalog:' }))
.toEqual({ type: 'found', resolution: { catalogName: 'default', specifier: '1.0.0' } })
})
test('resolves using explicit name', () => {
expect(resolveFromCatalog(catalogs, { alias: 'foo', pref: 'catalog:default' }))
.toEqual({ type: 'found', resolution: { catalogName: 'default', specifier: '1.0.0' } })
})
})
test('resolves named catalog', () => {
const catalogs = {
foo: {
bar: '1.0.0',
},
}
expect(resolveFromCatalog(catalogs, { alias: 'bar', pref: 'catalog:foo' }))
.toEqual({ type: 'found', resolution: { catalogName: 'foo', specifier: '1.0.0' } })
})
test('returns unused for specifier not using catalog protocol', () => {
const catalogs = {
foo: {
bar: '1.0.0',
},
}
expect(resolveFromCatalog(catalogs, { alias: 'bar', pref: '^2.0.0' })).toEqual({ type: 'unused' })
})
describe('misconfiguration', () => {
function resolveFromCatalogOrThrow (catalogs: Catalogs, wantedDependency: WantedDependency) {
const result = resolveFromCatalog(catalogs, wantedDependency)
if (result.type === 'misconfiguration') {
throw result.error
}
return result
}
test('returns error for missing unresolved catalog', () => {
const catalogs = {
foo: {
bar: '1.0.0',
},
}
expect(() => resolveFromCatalogOrThrow(catalogs, { alias: 'bar', pref: 'catalog:' }))
.toThrow("No catalog entry 'bar' was found for catalog 'default'.")
expect(() => resolveFromCatalogOrThrow(catalogs, { alias: 'bar', pref: 'catalog:baz' }))
.toThrow("No catalog entry 'bar' was found for catalog 'baz'.")
expect(() => resolveFromCatalogOrThrow(catalogs, { alias: 'foo', pref: 'catalog:foo' }))
.toThrow("No catalog entry 'foo' was found for catalog 'foo'.")
})
test('returns error for recursive catalog', () => {
const catalogs = {
foo: {
bar: 'catalog:foo',
},
}
expect(() => resolveFromCatalogOrThrow(catalogs, { alias: 'bar', pref: 'catalog:foo' }))
.toThrow("Found invalid catalog entry using the catalog protocol recursively. The entry for 'bar' in catalog 'foo' is invalid.")
})
})

View File

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

View File

@@ -0,0 +1,23 @@
{
"extends": "@pnpm/tsconfig",
"composite": true,
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../packages/error"
},
{
"path": "../protocol-parser"
},
{
"path": "../types"
}
]
}

View File

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

View File

@@ -0,0 +1,5 @@
# @pnpm/catalogs.types
## 0.1.0
Initial release

3
catalogs/types/README.md Normal file
View File

@@ -0,0 +1,3 @@
# @pnpm/catalogs.types
> Types related to the pnpm catalogs feature.

View File

@@ -0,0 +1,38 @@
{
"name": "@pnpm/catalogs.types",
"version": "0.1.0",
"description": "Types related to the pnpm catalogs feature.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"engines": {
"node": ">=18.12"
},
"files": [
"lib",
"!*.map"
],
"repository": "https://github.com/pnpm/pnpm/blob/main/catalogs/types",
"keywords": [
"pnpm9",
"pnpm",
"types"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/catalogs/types#readme",
"scripts": {
"lint": "eslint \"src/**/*.ts\"",
"compile": "tsc --build && pnpm run lint --fix",
"prepublishOnly": "pnpm run compile",
"test": "pnpm run compile"
},
"funding": "https://opencollective.com/pnpm",
"exports": {
".": "./lib/index.js"
},
"devDependencies": {
"@pnpm/catalogs.types": "workspace:*"
}
}

View File

@@ -0,0 +1,29 @@
/**
* Catalogs parsed from the pnpm-workspace.yaml file.
*
* https://github.com/pnpm/rfcs/pull/1
*/
export interface Catalogs {
/**
* The default catalog.
*
* The default catalog can be defined in 2 ways.
*
* 1. Users can specify a top-level "catalog" field or,
* 2. An explicitly named "default" catalog under the "catalogs" map.
*
* This field contains either definition. Note that it's an error to define
* the default catalog using both options. The parser will fail when reading
* the workspace manifest.
*/
readonly default?: Catalog
/**
* Named catalogs.
*/
readonly [catalogName: string]: Catalog | undefined
}
export interface Catalog {
readonly [dependencyName: string]: string | undefined
}

View File

@@ -0,0 +1,13 @@
{
"extends": "@pnpm/tsconfig",
"composite": true,
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": []
}

View File

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

View File

@@ -32,6 +32,8 @@
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/config/config#readme",
"dependencies": {
"@pnpm/catalogs.config": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/config.env-replace": "3.0.0",
"@pnpm/constants": "workspace:*",
"@pnpm/error": "workspace:*",

View File

@@ -1,3 +1,4 @@
import type { Catalogs } from '@pnpm/catalogs.types'
import {
type Project,
type ProjectManifest,
@@ -134,6 +135,7 @@ export interface Config {
workspaceConcurrency: number
workspaceDir?: string
workspacePackagePatterns?: string[]
catalogs?: Catalogs
reporter?: string
aggregateOutput: boolean
linkWorkspacePackages: boolean | 'deep'

View File

@@ -1,6 +1,7 @@
import path from 'path'
import fs from 'fs'
import os from 'os'
import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config'
import { LAYOUT_VERSION } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import loadNpmConf from '@pnpm/npm-conf'
@@ -487,12 +488,10 @@ export async function getConfig (opts: {
}
if (pnpmConfig.workspaceDir != null) {
if (cliOptions['workspace-packages']) {
pnpmConfig.workspacePackagePatterns = cliOptions['workspace-packages'] as string[]
} else {
const workspaceManifest = await readWorkspaceManifest(pnpmConfig.workspaceDir)
pnpmConfig.workspacePackagePatterns = workspaceManifest?.packages
}
const workspaceManifest = await readWorkspaceManifest(pnpmConfig.workspaceDir)
pnpmConfig.workspacePackagePatterns = cliOptions['workspace-packages'] as string[] ?? workspaceManifest?.packages
pnpmConfig.catalogs = getCatalogsFromWorkspaceManifest(workspaceManifest)
}
pnpmConfig.failedToLoadBuiltInConfig = failedToLoadBuiltInConfig

View File

@@ -15,6 +15,12 @@
{
"path": "../../__utils__/test-fixtures"
},
{
"path": "../../catalogs/config"
},
{
"path": "../../catalogs/types"
},
{
"path": "../../hooks/pnpmfile"
},

View File

@@ -116,6 +116,9 @@ function normalizeLockfile (lockfile: InlineSpecifiersLockfile, opts: NormalizeL
if (lockfileToSave.time) {
lockfileToSave.time = pruneTimeInLockfileV6(lockfileToSave.time, lockfile.importers ?? {})
}
if ((lockfileToSave.catalogs != null) && isEmpty(lockfileToSave.catalogs)) {
delete lockfileToSave.catalogs
}
if ((lockfileToSave.overrides != null) && isEmpty(lockfileToSave.overrides)) {
delete lockfileToSave.overrides
}

View File

@@ -35,6 +35,7 @@ type RootKey = keyof LockfileFile
const ROOT_KEYS: readonly RootKey[] = [
'lockfileVersion',
'settings',
'catalogs',
'overrides',
'packageExtensionsChecksum',
'pnpmfileChecksum',
@@ -86,6 +87,15 @@ export function sortLockfileKeys (lockfile: LockfileFileV9): LockfileFileV9 {
})
}
}
if (lockfile.catalogs != null) {
lockfile.catalogs = sortKeys(lockfile.catalogs)
for (const [catalogName, catalog] of Object.entries(lockfile.catalogs)) {
lockfile.catalogs[catalogName] = sortKeys(catalog, {
compare: lexCompare,
deep: true,
})
}
}
for (const key of ['dependencies', 'devDependencies', 'optionalDependencies', 'time', 'patchedDependencies'] as const) {
if (!lockfile[key]) continue
lockfile[key] = sortKeys<any>(lockfile[key]) // eslint-disable-line @typescript-eslint/no-explicit-any

View File

@@ -14,6 +14,7 @@ export interface Lockfile {
importers: Record<ProjectId, ProjectSnapshot>
lockfileVersion: string
time?: Record<string, string>
catalogs?: CatalogSnapshots
packages?: PackageSnapshots
overrides?: Record<string, string>
packageExtensionsChecksum?: string
@@ -136,3 +137,24 @@ export type PackageBin = string | { [name: string]: string }
* }
*/
export type ResolvedDependencies = Record<string, string>
export interface CatalogSnapshots {
[catalogName: string]: { [dependencyName: string]: ResolvedCatalogEntry }
}
export interface ResolvedCatalogEntry {
/**
* The real specifier that should be used for this dependency's catalog entry.
* This would be the ^1.2.3 portion of:
*
* @example
* catalog:
* foo: ^1.2.3
*/
readonly specifier: string
/**
* The concrete version that the requested specifier resolved to. Ex: 1.2.3
*/
readonly version: string
}

View File

@@ -36,7 +36,12 @@ export async function makeDedicatedLockfile (lockfileDir: string, projectDir: st
await writeWantedLockfile(projectDir, dedicatedLockfile)
const { manifest, writeProjectManifest } = await readProjectManifest(projectDir)
const publishManifest = await createExportableManifest(projectDir, manifest)
const publishManifest = await createExportableManifest(projectDir, manifest, {
// Since @pnpm/make-dedicated-lockfile is deprecated, avoid supporting new
// features like pnpm catalogs. Passing in an empty catalog object
// intentionally.
catalogs: {},
})
await writeProjectManifest(publishManifest)
const modulesDir = path.join(projectDir, 'node_modules')

View File

@@ -19,6 +19,8 @@
"@pnpm/build-modules": "workspace:*",
"@pnpm/builder.policy": "3.0.0",
"@pnpm/calc-dep-state": "workspace:*",
"@pnpm/catalogs.protocol-parser": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/crypto.base32-hash": "workspace:*",
@@ -89,6 +91,7 @@
"@pnpm/store.cafs": "workspace:*",
"@pnpm/test-fixtures": "workspace:*",
"@pnpm/test-ipc-server": "workspace:*",
"@pnpm/workspace.find-packages": "workspace:*",
"@types/fs-extra": "^9.0.13",
"@types/is-windows": "^1.0.2",
"@types/normalize-path": "^3.0.2",

View File

@@ -8,6 +8,7 @@ import { DEFAULT_REGISTRIES } from '@pnpm/normalize-registries'
export type ListMissingPeersOptions = Partial<GetContextOptions>
& Pick<InstallOptions, 'hooks'
| 'catalogs'
| 'dedupePeerDependents'
| 'ignoreCompatibilityDb'
| 'linkWorkspacePackagesDepth'
@@ -60,6 +61,7 @@ export async function getPeerDependencyIssues (
currentLockfile: ctx.currentLockfile,
allowedDeprecatedVersions: {},
allowNonAppliedPatches: false,
catalogs: opts.catalogs,
defaultUpdateDepth: -1,
dedupePeerDependents: opts.dedupePeerDependents,
dryRun: true,

View File

@@ -0,0 +1,11 @@
import { type CatalogSnapshots } from '@pnpm/lockfile-file'
import { type Catalogs } from '@pnpm/catalogs.types'
export function allCatalogsAreUpToDate (
catalogsConfig: Catalogs,
snapshot: CatalogSnapshots | undefined
): boolean {
return Object.entries(snapshot ?? {})
.every(([catalogName, catalog]) => Object.entries(catalog ?? {})
.every(([alias, entry]) => entry.specifier === catalogsConfig[catalogName]?.[alias]))
}

View File

@@ -1,4 +1,5 @@
import path from 'path'
import { type Catalogs } from '@pnpm/catalogs.types'
import { type ProjectOptions } from '@pnpm/get-context'
import {
type PackageSnapshot,
@@ -21,10 +22,12 @@ import pEvery from 'p-every'
import any from 'ramda/src/any'
import semver from 'semver'
import getVersionSelectorType from 'version-selector-type'
import { allCatalogsAreUpToDate } from './allCatalogsAreUpToDate'
export async function allProjectsAreUpToDate (
projects: Array<Pick<ProjectOptions, 'manifest' | 'rootDir'> & { id: ProjectId }>,
opts: {
catalogs: Catalogs
autoInstallPeers: boolean
excludeLinksFromLockfile: boolean
linkWorkspacePackages: boolean
@@ -33,6 +36,13 @@ export async function allProjectsAreUpToDate (
lockfileDir: string
}
): Promise<boolean> {
// Projects may declare dependencies using catalog protocol specifiers. If the
// catalog config definitions are edited by users, projects using them are out
// of date.
if (!allCatalogsAreUpToDate(opts.catalogs, opts.wantedLockfile.catalogs)) {
return false
}
const manifestsByDir = opts.workspacePackages ? getWorkspacePackagesByDirectory(opts.workspacePackages) : {}
const _satisfiesPackageManifest = satisfiesPackageManifest.bind(null, {
autoInstallPeers: opts.autoInstallPeers,

View File

@@ -1,4 +1,5 @@
import { WANTED_LOCKFILE } from '@pnpm/constants'
import { type Catalogs } from '@pnpm/catalogs.types'
import { PnpmError } from '@pnpm/error'
import { type ProjectOptions } from '@pnpm/get-context'
import { type HoistingLimits } from '@pnpm/headless'
@@ -22,6 +23,7 @@ import { type PreResolutionHookContext } from '@pnpm/hooks.types'
export interface StrictInstallOptions {
autoInstallPeers: boolean
autoInstallPeersFromHighestMatch: boolean
catalogs: Catalogs
frozenLockfile: boolean
frozenLockfileIfExists: boolean
enablePnp: boolean

View File

@@ -2,6 +2,7 @@ import crypto from 'crypto'
import path from 'path'
import { buildModules, type DepsStateCache, linkBinsOfDependencies } from '@pnpm/build-modules'
import { createAllowBuildFunction } from '@pnpm/builder.policy'
import { parseCatalogProtocol } from '@pnpm/catalogs.protocol-parser'
import {
LAYOUT_VERSION,
LOCKFILE_VERSION,
@@ -395,6 +396,7 @@ export async function mutateModules (
ctx.wantedLockfile.lockfileVersion === '6.1'
) &&
await allProjectsAreUpToDate(Object.values(ctx.projects), {
catalogs: opts.catalogs,
autoInstallPeers: opts.autoInstallPeers,
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
linkWorkspacePackages: opts.linkWorkspacePackagesDepth >= 0,
@@ -629,6 +631,28 @@ Note that in CI environments, this setting is enabled by default.`,
}
/* eslint-enable no-await-in-loop */
function isWantedDepPrefSame (alias: string, prevPref: string | undefined, nextPref: string): boolean {
if (prevPref !== nextPref) {
return false
}
// When pnpm catalogs are used, the specifiers can be the same (e.g.
// "catalog:default"), but the wanted versions for the dependency can be
// different after resolution if the catalog config was just edited.
const catalogName = parseCatalogProtocol(prevPref)
// If there's no catalog name, the catalog protocol was not used and we
// can assume the pref is the same since prevPref and nextPref match.
if (catalogName === null) {
return true
}
const prevCatalogEntrySpec = ctx.wantedLockfile.catalogs?.[catalogName]?.[alias]?.specifier
const nextCatalogEntrySpec = opts.catalogs[catalogName]?.[alias]
return prevCatalogEntrySpec === nextCatalogEntrySpec
}
async function installCase (project: any) { // eslint-disable-line
const wantedDependencies = getWantedDependencies(project.manifest, {
autoInstallPeers: opts.autoInstallPeers,
@@ -639,7 +663,7 @@ Note that in CI environments, this setting is enabled by default.`,
.map((wantedDependency) => ({ ...wantedDependency, updateSpec: true, preserveNonSemverVersionSpec: true }))
if (ctx.wantedLockfile?.importers) {
forgetResolutionsOfPrevWantedDeps(ctx.wantedLockfile.importers[project.id], wantedDependencies)
forgetResolutionsOfPrevWantedDeps(ctx.wantedLockfile.importers[project.id], wantedDependencies, isWantedDepPrefSame)
}
if (opts.ignoreScripts && project.manifest?.scripts &&
(project.manifest.scripts.preinstall ||
@@ -808,13 +832,17 @@ function pkgHasDependencies (manifest: ProjectManifest): boolean {
// If the specifier is new, the old resolution probably does not satisfy it anymore.
// By removing these resolutions we ensure that they are resolved again using the new specs.
function forgetResolutionsOfPrevWantedDeps (importer: ProjectSnapshot, wantedDeps: WantedDependency[]): void {
function forgetResolutionsOfPrevWantedDeps (
importer: ProjectSnapshot,
wantedDeps: WantedDependency[],
isWantedDepPrefSame: (alias: string, prevPref: string | undefined, nextPref: string) => boolean
): void {
if (!importer.specifiers) return
importer.dependencies = importer.dependencies ?? {}
importer.devDependencies = importer.devDependencies ?? {}
importer.optionalDependencies = importer.optionalDependencies ?? {}
for (const { alias, pref } of wantedDeps) {
if (alias && importer.specifiers[alias] !== pref) {
if (alias && !isWantedDepPrefSame(alias, importer.specifiers[alias], pref)) {
if (!importer.dependencies[alias]?.startsWith('link:')) {
delete importer.dependencies[alias]
}
@@ -1035,6 +1063,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
allowNonAppliedPatches: opts.allowNonAppliedPatches,
autoInstallPeers: opts.autoInstallPeers,
autoInstallPeersFromHighestMatch: opts.autoInstallPeersFromHighestMatch,
catalogs: opts.catalogs,
currentLockfile: ctx.currentLockfile,
defaultUpdateDepth: opts.depth,
dedupeDirectDeps: opts.dedupeDirectDeps,
@@ -1412,6 +1441,7 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => {
if (allProjectsLocatedInsideWorkspace.length > projects.length) {
if (
await allProjectsAreUpToDate(allProjectsLocatedInsideWorkspace, {
catalogs: opts.catalogs,
autoInstallPeers: opts.autoInstallPeers,
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
linkWorkspacePackages: opts.linkWorkspacePackagesDepth >= 0,

View File

@@ -36,6 +36,7 @@ test('allProjectsAreUpToDate(): works with packages linked through the workspace
},
], {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile: {
@@ -77,6 +78,7 @@ test('allProjectsAreUpToDate(): works with aliased local dependencies', async ()
},
], {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile: {
@@ -118,6 +120,7 @@ test('allProjectsAreUpToDate(): works with aliased local dependencies that speci
},
], {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile: {
@@ -159,6 +162,7 @@ test('allProjectsAreUpToDate(): returns false if the aliased dependency version
},
], {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile: {
@@ -230,6 +234,7 @@ test('allProjectsAreUpToDate(): use link and registry version if linkWorkspacePa
],
{
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: false,
wantedLockfile: {
@@ -296,6 +301,7 @@ test('allProjectsAreUpToDate(): returns false if dependenciesMeta differs', asyn
},
], {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile: {
@@ -342,6 +348,7 @@ test('allProjectsAreUpToDate(): returns true if dependenciesMeta matches', async
},
], {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile: {
@@ -400,6 +407,7 @@ describe('local file dependency', () => {
]
const options = {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile: {
@@ -499,6 +507,7 @@ test('allProjectsAreUpToDate(): returns true if workspace dependency\'s version
]
const options = {
autoInstallPeers: false,
catalogs: {},
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
wantedLockfile: {

View File

@@ -0,0 +1,371 @@
import { createPeersDirSuffix } from '@pnpm/dependency-path'
import { type ProjectId, type ProjectManifest } from '@pnpm/types'
import { prepareEmpty } from '@pnpm/prepare'
import { type MutatedProject, mutateModules, type ProjectOptions } from '@pnpm/core'
import { arrayOfWorkspacePackagesToMap } from '@pnpm/workspace.find-packages'
import path from 'path'
import { testDefaults } from './utils'
function preparePackagesAndReturnObjects (manifests: Array<ProjectManifest & Required<Pick<ProjectManifest, 'name'>>>) {
const project = prepareEmpty()
const projects: Record<ProjectId, ProjectManifest> = {}
for (const manifest of manifests) {
projects[manifest.name as ProjectId] = manifest
}
const allProjects: ProjectOptions[] = Object.entries(projects)
.map(([id, manifest]) => ({
buildIndex: 0,
manifest,
dir: path.resolve(id),
rootDir: path.resolve(id),
}))
return {
...project,
projects,
options: testDefaults({
allProjects,
workspacePackages: arrayOfWorkspacePackagesToMap(allProjects),
}),
}
}
function installProjects (projects: Record<ProjectId, ProjectManifest>): MutatedProject[] {
return Object.entries(projects)
.map(([id, manifest]) => ({
mutation: 'install',
id,
manifest,
rootDir: path.resolve(id),
}))
}
test('installing with "catalog:" should work', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
'is-positive': 'catalog:',
},
},
// Empty second project to create a multi-package workspace.
{
name: 'project2',
},
])
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs: {
default: { 'is-positive': '1.0.0' },
},
})
const lockfile = readLockfile()
expect(lockfile.importers['project1' as ProjectId]).toEqual({
dependencies: {
'is-positive': {
specifier: 'catalog:',
version: '1.0.0',
},
},
})
})
test('importer to importer dependency with "catalog:" should work', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
project2: 'workspace:*',
},
},
{
name: 'project2',
dependencies: {
'is-positive': 'catalog:',
},
},
])
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs: {
default: { 'is-positive': '1.0.0' },
},
})
const lockfile = readLockfile()
expect(lockfile.importers['project2' as ProjectId]).toEqual({
dependencies: {
'is-positive': {
specifier: 'catalog:',
version: '1.0.0',
},
},
})
})
test('importer with different peers uses correct peer', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
'@pnpm.e2e/has-foo100-peer': 'catalog:',
// Define a peer with an exact version to ensure the dep above uses
// this peer.
'@pnpm.e2e/foo': '100.0.0',
},
},
{
name: 'project2',
dependencies: {
'@pnpm.e2e/has-foo100-peer': 'catalog:',
// Note that this peer is intentionally different than the one above
// for project 1. (100.1.0 instead of 100.0.0).
//
// We want to ensure project2 resolves to the same catalog version for
// @pnpm.e2e/has-foo100-peer, but uses a different peers suffix.
//
// Catalogs allow versions to be reused, but this test ensures we
// don't reuse versions too aggressively.
'@pnpm.e2e/foo': '100.1.0',
},
},
])
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs: {
default: {
'@pnpm.e2e/has-foo100-peer': '^1.0.0',
},
},
})
const lockfile = readLockfile()
expect(lockfile.importers['project1' as ProjectId]?.dependencies?.['@pnpm.e2e/has-foo100-peer']).toEqual({
specifier: 'catalog:',
version: `1.0.0${createPeersDirSuffix([{ name: '@pnpm.e2e/foo', version: '100.0.0' }])}`,
})
expect(lockfile.importers['project2' as ProjectId]?.dependencies?.['@pnpm.e2e/has-foo100-peer']).toEqual({
specifier: 'catalog:',
// This version is intentionally different from the one above ꜜ
version: `1.0.0${createPeersDirSuffix([{ name: '@pnpm.e2e/foo', version: '100.1.0' }])}`,
})
})
test('lockfile contains catalog snapshots', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
'is-positive': 'catalog:',
},
},
{
name: 'project2',
dependencies: {
'is-negative': 'catalog:',
},
},
])
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs: {
default: {
'is-positive': '^1.0.0',
'is-negative': '^1.0.0',
},
},
})
const lockfile = readLockfile()
expect(lockfile.catalogs).toStrictEqual({
default: {
'is-positive': { specifier: '^1.0.0', version: '1.0.0' },
'is-negative': { specifier: '^1.0.0', version: '1.0.0' },
},
})
})
test('lockfile is updated if catalog config changes', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
'is-positive': 'catalog:',
},
},
])
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs: {
default: {
'is-positive': '=1.0.0',
},
},
})
expect(readLockfile().importers['project1' as ProjectId]).toEqual({
dependencies: {
'is-positive': {
specifier: 'catalog:',
version: '1.0.0',
},
},
})
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs: {
default: {
'is-positive': '=3.1.0',
},
},
})
expect(readLockfile().importers['project1' as ProjectId]).toEqual({
dependencies: {
'is-positive': {
specifier: 'catalog:',
version: '3.1.0',
},
},
})
})
test('lockfile catalog snapshots retain existing entries on --filter', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
'is-negative': 'catalog:',
},
},
{
name: 'project2',
dependencies: {
'is-positive': 'catalog:',
},
},
])
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs: {
default: {
'is-positive': '^1.0.0',
'is-negative': '^1.0.0',
},
},
})
expect(readLockfile().catalogs).toStrictEqual({
default: {
'is-negative': { specifier: '^1.0.0', version: '1.0.0' },
'is-positive': { specifier: '^1.0.0', version: '1.0.0' },
},
})
// Update catalog definitions so pnpm triggers a rerun.
await mutateModules(installProjects(projects).slice(1), {
...options,
lockfileOnly: true,
catalogs: {
default: {
'is-positive': '=3.1.0',
'is-negative': '^1.0.0',
},
},
})
expect(readLockfile().catalogs).toStrictEqual({
default: {
// The is-negative snapshot should be carried from the previous install,
// despite the current filtered install not using it.
'is-negative': { specifier: '^1.0.0', version: '1.0.0' },
'is-positive': { specifier: '=3.1.0', version: '3.1.0' },
},
})
})
// If a catalog specifier was used in one or more package.json files and all
// usages were removed later, we should remove the catalog snapshot from
// pnpm-lock.yaml. This should happen even if the dependency is still defined in
// a catalog under pnpm-workspace.yaml.
//
// Note that this behavior may not be desirable in all cases. If someone removes
// the last usage of a catalog entry, and another person adds it back later,
// that dependency will be re-resolved to a newer version. This is probably
// desirable most of the time, but there could be a good argument to cache the
// older unused resolution. For now we'll remove the unused entries since that's
// what would happen anyway if catalogs aren't used.
test('lockfile catalog snapshots should remove unused entries', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
'is-negative': 'catalog:',
'is-positive': 'catalog:',
},
},
])
const catalogs = {
default: {
'is-negative': '=1.0.0',
'is-positive': '=1.0.0',
},
}
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs,
})
{
const lockfile = readLockfile()
expect(lockfile.importers['project1' as ProjectId]?.dependencies).toEqual({
'is-negative': { specifier: 'catalog:', version: '1.0.0' },
'is-positive': { specifier: 'catalog:', version: '1.0.0' },
})
expect(lockfile.catalogs?.default).toStrictEqual({
'is-negative': { specifier: '=1.0.0', version: '1.0.0' },
'is-positive': { specifier: '=1.0.0', version: '1.0.0' },
})
}
// Update package.json to no longer depend on is-positive.
projects['project1' as ProjectId].dependencies = {
'is-negative': 'catalog:',
}
await mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
catalogs,
})
{
const lockfile = readLockfile()
expect(lockfile.importers['project1' as ProjectId]?.dependencies).toEqual({
'is-negative': { specifier: 'catalog:', version: '1.0.0' },
})
// Only "is-negative" should be in the catalogs section of the lockfile
// since all packages in the workspace no longer use is-positive. Note that
// this should be the case even if pnpm-workspace.yaml still has
// "is-positive" configured.
expect(lockfile.catalogs?.default).toStrictEqual({
'is-negative': { specifier: '=1.0.0', version: '1.0.0' },
})
}
})

View File

@@ -24,6 +24,12 @@
{
"path": "../../__utils__/test-ipc-server"
},
{
"path": "../../catalogs/protocol-parser"
},
{
"path": "../../catalogs/types"
},
{
"path": "../../config/matcher"
},
@@ -135,6 +141,9 @@
{
"path": "../../worker"
},
{
"path": "../../workspace/find-packages"
},
{
"path": "../client"
},

View File

@@ -29,6 +29,8 @@
"_test": "jest"
},
"dependencies": {
"@pnpm/catalogs.resolver": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/dependency-path": "workspace:*",

View File

@@ -0,0 +1,21 @@
import { type CatalogSnapshots } from '@pnpm/lockfile-types'
import { type ResolvedDirectDependency } from './resolveDependencyTree'
export function getCatalogSnapshots (resolvedDirectDeps: readonly ResolvedDirectDependency[]): CatalogSnapshots {
const catalogSnapshots: CatalogSnapshots = {}
const catalogedDeps = resolvedDirectDeps.filter(isCatalogedDep)
for (const dep of catalogedDeps) {
const snapshotForSingleCatalog = (catalogSnapshots[dep.catalogLookup.catalogName] ??= {})
snapshotForSingleCatalog[dep.alias] = {
specifier: dep.catalogLookup.specifier,
version: dep.version,
}
}
return catalogSnapshots
}
function isCatalogedDep (dep: ResolvedDirectDependency): dep is ResolvedDirectDependency & { catalogLookup: Required<ResolvedDirectDependency>['catalogLookup'] } {
return dep.catalogLookup != null
}

View File

@@ -46,6 +46,7 @@ import {
import { toResolveImporter } from './toResolveImporter'
import { updateLockfile } from './updateLockfile'
import { updateProjectManifest } from './updateProjectManifest'
import { getCatalogSnapshots } from './getCatalogSnapshots'
export type DependenciesGraph = GenericDependenciesGraphWithResolvedChildren<ResolvedPackage>
@@ -304,6 +305,8 @@ export async function resolveDependencies (
}
}
newLockfile.catalogs = getCatalogSnapshots(Object.values(resolvedImporters).flatMap(({ directDependencies }) => directDependencies))
// waiting till package requests are finished
async function waitTillAllFetchingsFinish (): Promise<void> {
await Promise.all(Object.values(resolvedPkgsById).map(async ({ fetching }) => {

View File

@@ -1,4 +1,5 @@
import path from 'path'
import { matchCatalogResolveResult, type CatalogResolver } from '@pnpm/catalogs.resolver'
import {
deprecationLogger,
progressLogger,
@@ -56,6 +57,7 @@ import { type NodeId, nextNodeId } from './nextNodeId'
import { parentIdsContainSequence } from './parentIdsContainSequence'
import { hoistPeers, getHoistableOptionalPeers } from './hoistPeers'
import { wantedDepIsLocallyAvailable } from './wantedDepIsLocallyAvailable'
import { type CatalogLookupMetadata } from './resolveDependencyTree'
import { replaceVersionInPref } from './replaceVersionInPref'
const dependencyResolvedLogger = logger('_dependency_resolved')
@@ -110,6 +112,7 @@ export interface LinkedDependency {
name: string
normalizedPref?: string
alias: string
catalogLookup?: CatalogLookupMetadata
}
export interface PendingNode {
@@ -137,6 +140,7 @@ export interface ResolutionContext {
allPreferredVersions?: PreferredVersions
appliedPatches: Set<string>
updatedSet: Set<string>
catalogResolver: CatalogResolver
defaultTag: string
dryRun: boolean
forceFullResolution: boolean
@@ -196,6 +200,7 @@ export type PkgAddress = {
missingPeers: MissingPeers
missingPeersOfChildren?: MissingPeersOfChildren
publishedAt?: string
catalogLookup?: CatalogLookupMetadata
optional: boolean
} & ({
isLinkedDependency: true
@@ -409,18 +414,12 @@ async function resolveDependenciesOfImporters (
const postponedPeersResolutionQueue: PostponedPeersResolutionFunction[] = []
const pkgAddresses: PkgAddress[] = []
const resolvedDependenciesOfImporter = await Promise.all(
extendedWantedDeps.map((extendedWantedDep) => resolveDependenciesOfDependency(
ctx,
importer.preferredVersions,
{
...importer.options,
parentPkgAliases: importer.parentPkgAliases,
pickLowestVersion: pickLowestVersion && !importer.updatePackageManifest,
},
extendedWantedDep
))
)
const resolveDependenciesOfImporterWantedDep = resolveDependenciesOfImporterDependency.bind(null, {
ctx,
importer,
pickLowestVersion,
})
const resolvedDependenciesOfImporter = await Promise.all(extendedWantedDeps.map(resolveDependenciesOfImporterWantedDep))
for (const { resolveDependencyResult, postponedPeersResolution, postponedResolution } of resolvedDependenciesOfImporter) {
if (resolveDependencyResult) {
@@ -511,6 +510,51 @@ async function resolveDependenciesOfImporters (
}
}
export interface ResolveDependenciesOfImporterDependencyOpts {
readonly ctx: ResolutionContext
readonly importer: ImporterToResolve
readonly pickLowestVersion: boolean
}
async function resolveDependenciesOfImporterDependency (
{ ctx, importer, pickLowestVersion }: ResolveDependenciesOfImporterDependencyOpts,
extendedWantedDep: ExtendedWantedDependency
): Promise<ResolveDependenciesOfDependency> {
// The catalog protocol is only usable in importers (i.e. packages in the
// workspace. Replacing catalog protocol while resolving importers here before
// resolving dependencies of packages outside of the workspace/monorepo.
const catalogLookup = matchCatalogResolveResult(ctx.catalogResolver(extendedWantedDep.wantedDependency), {
found: (result) => result.resolution,
unused: () => undefined,
misconfiguration: (result) => {
throw result.error
},
})
if (catalogLookup != null) {
extendedWantedDep.wantedDependency.pref = catalogLookup.specifier
}
const result = await resolveDependenciesOfDependency(
ctx,
importer.preferredVersions,
{
...importer.options,
parentPkgAliases: importer.parentPkgAliases,
pickLowestVersion: pickLowestVersion && !importer.updatePackageManifest,
},
extendedWantedDep
)
// If the catalog protocol was used, store metadata about the catalog
// lookup to use in the lockfile.
if (result.resolveDependencyResult != null && catalogLookup != null) {
result.resolveDependencyResult.catalogLookup = catalogLookup
}
return result
}
function filterMissingPeersFromPkgAddresses (
pkgAddresses: PkgAddress[],
currentParentPkgAliases: ParentPkgAliases,

View File

@@ -1,3 +1,5 @@
import { resolveFromCatalog } from '@pnpm/catalogs.resolver'
import { type Catalogs } from '@pnpm/catalogs.types'
import { type Lockfile, type PatchFile } from '@pnpm/lockfile-types'
import { type PreferredVersions, type Resolution, type WorkspacePackages } from '@pnpm/resolver-base'
import { type StoreController } from '@pnpm/store-controller-types'
@@ -49,6 +51,16 @@ export interface ResolvedDirectDependency {
version: string
name: string
normalizedPref?: string
catalogLookup?: CatalogLookupMetadata
}
/**
* Information related to the catalog entry for this dependency if it was
* requested through the catalog protocol.
*/
export interface CatalogLookupMetadata {
readonly catalogName: string
readonly specifier: string
}
export interface Importer<WantedDepExtraProps> {
@@ -74,6 +86,7 @@ export interface ResolveDependenciesOptions {
allowBuild?: (pkgName: string) => boolean
allowedDeprecatedVersions: AllowedDeprecatedVersions
allowNonAppliedPatches: boolean
catalogs?: Catalogs
currentLockfile: Lockfile
dedupePeerDependents?: boolean
dryRun: boolean
@@ -129,6 +142,7 @@ export async function resolveDependencyTree<T> (
autoInstallPeersFromHighestMatch: opts.autoInstallPeersFromHighestMatch === true,
allowBuild: opts.allowBuild,
allowedDeprecatedVersions: opts.allowedDeprecatedVersions,
catalogResolver: resolveFromCatalog.bind(null, opts.catalogs ?? {}),
childrenByParentId: {} as ChildrenByParentId,
currentLockfile: opts.currentLockfile,
defaultTag: opts.tag,
@@ -229,6 +243,7 @@ export async function resolveDependencyTree<T> (
const resolvedPackage = ctx.dependenciesTree.get(dep.nodeId)!.resolvedPackage as ResolvedPackage
return {
alias: dep.alias,
catalogLookup: dep.catalogLookup,
dev: resolvedPackage.dev,
name: resolvedPackage.name,
normalizedPref: dep.normalizedPref,

View File

@@ -9,6 +9,12 @@
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../catalogs/resolver"
},
{
"path": "../../catalogs/types"
},
{
"path": "../../config/pick-registry-for-package"
},

View File

@@ -29,6 +29,8 @@
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/pkg-manifest/exportable-manifest#readme",
"devDependencies": {
"@pnpm/catalogs.config": "workspace:*",
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/exportable-manifest": "workspace:*",
"@pnpm/prepare": "workspace:*",
"@types/cross-spawn": "^6.0.6",
@@ -39,6 +41,7 @@
"dependencies": {
"@pnpm/error": "workspace:*",
"@pnpm/read-project-manifest": "workspace:*",
"@pnpm/catalogs.resolver": "workspace:*",
"@pnpm/types": "workspace:*",
"p-map-values": "^1.0.0",
"ramda": "npm:@pnpm/ramda@0.28.1"

View File

@@ -1,4 +1,6 @@
import path from 'path'
import { type CatalogResolver, resolveFromCatalog } from '@pnpm/catalogs.resolver'
import { type Catalogs } from '@pnpm/catalogs.types'
import { PnpmError } from '@pnpm/error'
import { tryReadProjectManifest } from '@pnpm/read-project-manifest'
import { type Dependencies, type ProjectManifest } from '@pnpm/types'
@@ -16,6 +18,7 @@ const PREPUBLISH_SCRIPTS = [
]
export interface MakePublishManifestOptions {
catalogs: Catalogs
modulesDir?: string
readmeFile?: string
}
@@ -23,14 +26,22 @@ export interface MakePublishManifestOptions {
export async function createExportableManifest (
dir: string,
originalManifest: ProjectManifest,
opts?: MakePublishManifestOptions
opts: MakePublishManifestOptions
): Promise<ProjectManifest> {
const publishManifest: ProjectManifest = omit(['pnpm', 'scripts', 'packageManager'], originalManifest)
if (originalManifest.scripts != null) {
publishManifest.scripts = omit(PREPUBLISH_SCRIPTS, originalManifest.scripts)
}
const catalogResolver = resolveFromCatalog.bind(null, opts.catalogs)
const replaceCatalogProtocol = resolveCatalogProtocol.bind(null, catalogResolver)
const convertDependencyForPublish = combineConverters(replaceWorkspaceProtocol, replaceCatalogProtocol)
await Promise.all((['dependencies', 'devDependencies', 'optionalDependencies'] as const).map(async (depsField) => {
const deps = await makePublishDependencies(dir, originalManifest[depsField], { modulesDir: opts?.modulesDir })
const deps = await makePublishDependencies(dir, originalManifest[depsField], {
modulesDir: opts?.modulesDir,
convertDependencyForPublish,
})
if (deps != null) {
publishManifest[depsField] = deps
}
@@ -38,7 +49,11 @@ export async function createExportableManifest (
const peerDependencies = originalManifest.peerDependencies
if (peerDependencies) {
publishManifest.peerDependencies = await makePublishDependencies(dir, peerDependencies, { modulesDir: opts?.modulesDir, convertDependencyForPublish: makePublishPeerDependency })
const convertPeersForPublish = combineConverters(replaceWorkspaceProtocolPeerDependency, replaceCatalogProtocol)
publishManifest.peerDependencies = await makePublishDependencies(dir, peerDependencies, {
modulesDir: opts?.modulesDir,
convertDependencyForPublish: convertPeersForPublish,
})
}
overridePublishConfig(publishManifest)
@@ -50,17 +65,37 @@ export async function createExportableManifest (
return publishManifest
}
export type PublishDependencyConverter = (
depName: string,
depSpec: string,
dir: string,
modulesDir?: string
) => Promise<string> | string
function combineConverters (...converters: readonly PublishDependencyConverter[]): PublishDependencyConverter {
return async (depName, depSpec, dir, modulesDir) => {
let pref = depSpec
for (const converter of converters) {
// eslint-disable-next-line no-await-in-loop
pref = await converter(depName, pref, dir, modulesDir)
}
return pref
}
}
export interface MakePublishDependenciesOpts {
readonly modulesDir?: string
readonly convertDependencyForPublish: PublishDependencyConverter
}
async function makePublishDependencies (
dir: string,
dependencies: Dependencies | undefined,
{ modulesDir, convertDependencyForPublish = makePublishDependency }: {
modulesDir?: string
convertDependencyForPublish?: (depName: string, depSpec: string, dir: string, modulesDir?: string) => Promise<string>
} = {}
{ modulesDir, convertDependencyForPublish }: MakePublishDependenciesOpts
): Promise<Dependencies | undefined> {
if (dependencies == null) return dependencies
const publishDependencies = await pMapValues(
(depSpec, depName) => convertDependencyForPublish(depName, depSpec, dir, modulesDir),
async (depSpec, depName) => convertDependencyForPublish(depName, depSpec, dir, modulesDir),
dependencies
)
return publishDependencies
@@ -79,7 +114,17 @@ async function resolveManifest (depName: string, modulesDir: string): Promise<Pr
return manifest
}
async function makePublishDependency (depName: string, depSpec: string, dir: string, modulesDir?: string): Promise<string> {
function resolveCatalogProtocol (catalogResolver: CatalogResolver, alias: string, pref: string): string {
const result = catalogResolver({ alias, pref })
switch (result.type) {
case 'found': return result.resolution.specifier
case 'unused': return pref
case 'misconfiguration': throw result.error
}
}
async function replaceWorkspaceProtocol (depName: string, depSpec: string, dir: string, modulesDir?: string): Promise<string> {
if (!depSpec.startsWith('workspace:')) {
return depSpec
}
@@ -109,7 +154,7 @@ async function makePublishDependency (depName: string, depSpec: string, dir: str
return depSpec
}
async function makePublishPeerDependency (depName: string, depSpec: string, dir: string, modulesDir?: string) {
async function replaceWorkspaceProtocolPeerDependency (depName: string, depSpec: string, dir: string, modulesDir?: string) {
if (!depSpec.includes('workspace:')) {
return depSpec
}

View File

@@ -1,5 +1,6 @@
/// <reference path="../../../__typings__/index.d.ts"/>
import { createExportableManifest } from '@pnpm/exportable-manifest'
import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config'
import { type MakePublishManifestOptions, createExportableManifest } from '@pnpm/exportable-manifest'
import { preparePackages } from '@pnpm/prepare'
import { sync as writeYamlFile } from 'write-yaml-file'
import { type ProjectManifest } from '@pnpm/types'
@@ -8,6 +9,10 @@ import path from 'path'
const pnpmBin = path.join(__dirname, '../../../pnpm/bin/pnpm.cjs')
const defaultOpts: MakePublishManifestOptions = {
catalogs: {},
}
test('the pnpm options are removed', async () => {
expect(await createExportableManifest(process.cwd(), {
name: 'foo',
@@ -20,7 +25,7 @@ test('the pnpm options are removed', async () => {
bar: '1',
},
},
})).toStrictEqual({
}, defaultOpts)).toStrictEqual({
name: 'foo',
version: '1.0.0',
dependencies: {
@@ -37,7 +42,7 @@ test('the packageManager field is removed', async () => {
qar: '2',
},
packageManager: 'pnpm@8.0.0',
})).toStrictEqual({
}, defaultOpts)).toStrictEqual({
name: 'foo',
version: '1.0.0',
dependencies: {
@@ -60,7 +65,7 @@ test('publish lifecycle scripts are removed', async () => {
postinstall: 'echo',
test: 'echo',
},
})).toStrictEqual({
}, defaultOpts)).toStrictEqual({
name: 'foo',
version: '1.0.0',
scripts: {
@@ -74,7 +79,7 @@ test('readme added to published manifest', async () => {
expect(await createExportableManifest(process.cwd(), {
name: 'foo',
version: '1.0.0',
}, { readmeFile: 'readme content' })).toStrictEqual({
}, { ...defaultOpts, readmeFile: 'readme content' })).toStrictEqual({
name: 'foo',
version: '1.0.0',
readme: 'readme content',
@@ -119,7 +124,7 @@ test('workspace deps are replaced', async () => {
process.chdir('workspace-protocol-package')
expect(await createExportableManifest(process.cwd(), workspaceProtocolPackageManifest)).toStrictEqual({
expect(await createExportableManifest(process.cwd(), workspaceProtocolPackageManifest, defaultOpts)).toStrictEqual({
name: 'workspace-protocol-package',
version: '1.0.0',
dependencies: {
@@ -133,3 +138,57 @@ test('workspace deps are replaced', async () => {
},
})
})
test('catalog deps are replace', async () => {
const catalogProtocolPackageManifest: ProjectManifest = {
name: 'catalog-protocol-package',
version: '1.0.0',
dependencies: {
bar: 'catalog:',
},
optionalDependencies: {
baz: 'catalog:baz',
},
peerDependencies: {
foo: 'catalog:foo',
},
}
preparePackages([catalogProtocolPackageManifest])
const workspaceManifest = {
packages: ['**', '!store/**'],
catalog: {
bar: '^1.2.3',
},
catalogs: {
foo: {
foo: '^1.2.4',
},
baz: {
baz: '^1.2.5',
},
},
}
writeYamlFile('pnpm-workspace.yaml', workspaceManifest)
crossSpawn.sync(pnpmBin, ['install', '--store-dir=store'])
process.chdir('catalog-protocol-package')
const catalogs = getCatalogsFromWorkspaceManifest(workspaceManifest)
expect(await createExportableManifest(process.cwd(), catalogProtocolPackageManifest, { catalogs })).toStrictEqual({
name: 'catalog-protocol-package',
version: '1.0.0',
dependencies: {
bar: '^1.2.3',
},
optionalDependencies: {
baz: '^1.2.5',
},
peerDependencies: {
foo: '^1.2.4',
},
})
})

View File

@@ -12,6 +12,15 @@
{
"path": "../../__utils__/prepare"
},
{
"path": "../../catalogs/config"
},
{
"path": "../../catalogs/resolver"
},
{
"path": "../../catalogs/types"
},
{
"path": "../../packages/error"
},

80
pnpm-lock.yaml generated
View File

@@ -365,6 +365,50 @@ importers:
specifier: workspace:*
version: 'link:'
catalogs/config:
dependencies:
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
devDependencies:
'@pnpm/catalogs.config':
specifier: workspace:*
version: 'link:'
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../types
'@pnpm/workspace.read-manifest':
specifier: workspace:*
version: link:../../workspace/read-manifest
catalogs/protocol-parser:
devDependencies:
'@pnpm/catalogs.protocol-parser':
specifier: workspace:*
version: 'link:'
catalogs/resolver:
dependencies:
'@pnpm/catalogs.protocol-parser':
specifier: workspace:^
version: link:../protocol-parser
'@pnpm/error':
specifier: workspace:^
version: link:../../packages/error
devDependencies:
'@pnpm/catalogs.resolver':
specifier: workspace:*
version: 'link:'
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../types
catalogs/types:
devDependencies:
'@pnpm/catalogs.types':
specifier: workspace:*
version: 'link:'
cli/cli-meta:
dependencies:
'@pnpm/types':
@@ -598,6 +642,12 @@ importers:
config/config:
dependencies:
'@pnpm/catalogs.config':
specifier: workspace:*
version: link:../../catalogs/config
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../../catalogs/types
'@pnpm/config.env-replace':
specifier: 3.0.0
version: 3.0.0
@@ -3084,6 +3134,12 @@ importers:
'@pnpm/calc-dep-state':
specifier: workspace:*
version: link:../../packages/calc-dep-state
'@pnpm/catalogs.protocol-parser':
specifier: workspace:*
version: link:../../catalogs/protocol-parser
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../../catalogs/types
'@pnpm/constants':
specifier: workspace:*
version: link:../../packages/constants
@@ -3295,6 +3351,9 @@ importers:
'@pnpm/test-ipc-server':
specifier: workspace:*
version: link:../../__utils__/test-ipc-server
'@pnpm/workspace.find-packages':
specifier: workspace:*
version: link:../../workspace/find-packages
'@types/fs-extra':
specifier: ^9.0.13
version: 9.0.13
@@ -4271,6 +4330,12 @@ importers:
pkg-manager/resolve-dependencies:
dependencies:
'@pnpm/catalogs.resolver':
specifier: workspace:*
version: link:../../catalogs/resolver
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../../catalogs/types
'@pnpm/constants':
specifier: workspace:*
version: link:../../packages/constants
@@ -4392,6 +4457,9 @@ importers:
pkg-manifest/exportable-manifest:
dependencies:
'@pnpm/catalogs.resolver':
specifier: workspace:*
version: link:../../catalogs/resolver
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
@@ -4408,6 +4476,12 @@ importers:
specifier: npm:@pnpm/ramda@0.28.1
version: '@pnpm/ramda@0.28.1'
devDependencies:
'@pnpm/catalogs.config':
specifier: workspace:*
version: link:../../catalogs/config
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../../catalogs/types
'@pnpm/exportable-manifest':
specifier: workspace:*
version: 'link:'
@@ -4962,6 +5036,9 @@ importers:
releasing/plugin-commands-publishing:
dependencies:
'@pnpm/catalogs.types':
specifier: workspace:*
version: link:../../catalogs/types
'@pnpm/cli-utils':
specifier: workspace:*
version: link:../../cli/cli-utils
@@ -5047,6 +5124,9 @@ importers:
specifier: ^4.3.0
version: 4.3.0
devDependencies:
'@pnpm/catalogs.config':
specifier: workspace:*
version: link:../../catalogs/config
'@pnpm/plugin-commands-publishing':
specifier: workspace:*
version: 'link:'

View File

@@ -3,6 +3,7 @@ packages:
- __typings__
- __utils__/*
- "!__utils__/build-artifacts"
- catalogs/*
- cli/*
- completion/*
- config/*

View File

@@ -32,6 +32,7 @@
},
"homepage": "https://github.com/pnpm/pnpm/blob/main/releasing/plugin-commands-publishing#readme",
"devDependencies": {
"@pnpm/catalogs.config": "workspace:*",
"@pnpm/plugin-commands-publishing": "workspace:*",
"@pnpm/prepare": "workspace:*",
"@pnpm/registry-mock": "3.32.1",
@@ -53,6 +54,7 @@
"write-yaml-file": "^5.0.0"
},
"dependencies": {
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/cli-utils": "workspace:*",
"@pnpm/client": "workspace:*",
"@pnpm/common-cli-options-help": "workspace:*",

View File

@@ -1,6 +1,7 @@
import fs from 'fs'
import path from 'path'
import { createGzip } from 'zlib'
import { type Catalogs } from '@pnpm/catalogs.types'
import { PnpmError } from '@pnpm/error'
import { types as allTypes, type UniversalOptions, type Config } from '@pnpm/config'
import { readProjectManifest } from '@pnpm/cli-utils'
@@ -58,7 +59,7 @@ export function help (): string {
}
export async function handler (
opts: Pick<UniversalOptions, 'dir'> & Pick<Config, 'ignoreScripts' | 'rawConfig' | 'embedReadme' | 'packGzipLevel' | 'nodeLinker'> & Partial<Pick<Config, 'extraBinPaths' | 'extraEnv'>> & {
opts: Pick<UniversalOptions, 'dir'> & Pick<Config, 'catalogs' | 'ignoreScripts' | 'rawConfig' | 'embedReadme' | 'packGzipLevel' | 'nodeLinker'> & Partial<Pick<Config, 'extraBinPaths' | 'extraEnv'>> & {
argv: {
original: string[]
}
@@ -103,6 +104,7 @@ export async function handler (
modulesDir: path.join(opts.dir, 'node_modules'),
manifest,
embedReadme: opts.embedReadme,
catalogs: opts.catalogs ?? {},
})
const files = await packlist(dir, {
packageJsonCache: {
@@ -202,8 +204,13 @@ async function createPublishManifest (opts: {
embedReadme?: boolean
modulesDir: string
manifest: ProjectManifest
catalogs: Catalogs
}): Promise<ProjectManifest> {
const { projectDir, embedReadme, modulesDir, manifest } = opts
const { projectDir, embedReadme, modulesDir, manifest, catalogs } = opts
const readmeFile = embedReadme ? await readReadmeFile(projectDir) : undefined
return createExportableManifest(projectDir, manifest, { readmeFile, modulesDir })
return createExportableManifest(projectDir, manifest, {
catalogs,
readmeFile,
modulesDir,
})
}

View File

@@ -22,6 +22,7 @@ export type PublishRecursiveOpts = Required<Pick<Config,
Partial<Pick<Config,
| 'tag'
| 'ca'
| 'catalogs'
| 'cert'
| 'fetchTimeout'
| 'force'

View File

@@ -3,6 +3,7 @@ import path from 'path'
import execa from 'execa'
import { isCI } from 'ci-info'
import isWindows from 'is-windows'
import { getCatalogsFromWorkspaceManifest } from '@pnpm/catalogs.config'
import { pack, publish } from '@pnpm/plugin-commands-publishing'
import { prepare, preparePackages } from '@pnpm/prepare'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
@@ -613,6 +614,127 @@ test.skip('convert specs with relative workspace protocols to regular version ra
})
})
describe('catalog protocol converted when publishing', () => {
test('default catalog', async () => {
const testPackageName = 'workspace-package-with-default-catalog'
preparePackages([
{
name: testPackageName,
version: '1.0.0',
dependencies: {
'is-positive': 'catalog:',
},
devDependencies: {
'is-positive': 'catalog:',
},
optionalDependencies: {
'is-positive': 'catalog:',
},
peerDependencies: {
'is-positive': 'catalog:',
},
},
{
name: 'target',
private: true,
},
])
const workspaceManifest = {
packages: ['**', '!store/**'],
catalog: { 'is-positive': '1.0.0' },
}
writeYamlFile('pnpm-workspace.yaml', workspaceManifest)
process.chdir(testPackageName)
await publish.handler({
...DEFAULT_OPTS,
argv: { original: ['publish', ...CREDENTIALS] },
catalogs: getCatalogsFromWorkspaceManifest(workspaceManifest),
dir: process.cwd(),
}, [])
process.chdir('../target')
crossSpawn.sync(pnpmBin, [
'add',
'--store-dir=../store',
testPackageName,
'--no-link-workspace-packages',
`--registry=http://localhost:${REGISTRY_MOCK_PORT}`,
])
const { default: publishedManifest } = await import(path.resolve(`node_modules/${testPackageName}/package.json`))
expect(publishedManifest.dependencies).toStrictEqual({ 'is-positive': '1.0.0' })
expect(publishedManifest.devDependencies).toStrictEqual({ 'is-positive': '1.0.0' })
expect(publishedManifest.optionalDependencies).toStrictEqual({ 'is-positive': '1.0.0' })
expect(publishedManifest.peerDependencies).toStrictEqual({ 'is-positive': '1.0.0' })
})
test('named catalog', async () => {
const testPackageName = 'workspace-package-with-named-catalog'
preparePackages([
{
name: testPackageName,
version: '1.0.0',
dependencies: {
'is-positive': 'catalog:foo',
},
devDependencies: {
'is-positive': 'catalog:bar',
},
optionalDependencies: {
'is-positive': 'catalog:baz',
},
peerDependencies: {
'is-positive': 'catalog:qux',
},
},
{
name: 'target',
private: true,
},
])
const workspaceManifest = {
packages: ['**', '!store/**'],
catalogs: {
foo: { 'is-positive': '1.0.0' },
bar: { 'is-positive': '1.0.0' },
baz: { 'is-positive': '1.0.0' },
qux: { 'is-positive': '1.0.0' },
},
}
writeYamlFile('pnpm-workspace.yaml', workspaceManifest)
process.chdir(testPackageName)
await publish.handler({
...DEFAULT_OPTS,
argv: { original: ['publish', ...CREDENTIALS] },
catalogs: getCatalogsFromWorkspaceManifest(workspaceManifest),
dir: process.cwd(),
}, [])
process.chdir('../target')
crossSpawn.sync(pnpmBin, [
'add',
'--store-dir=../store',
testPackageName,
'--no-link-workspace-packages',
`--registry=http://localhost:${REGISTRY_MOCK_PORT}`,
])
const { default: publishedManifest } = await import(path.resolve(`node_modules/${testPackageName}/package.json`))
expect(publishedManifest.dependencies).toStrictEqual({ 'is-positive': '1.0.0' })
expect(publishedManifest.devDependencies).toStrictEqual({ 'is-positive': '1.0.0' })
expect(publishedManifest.optionalDependencies).toStrictEqual({ 'is-positive': '1.0.0' })
expect(publishedManifest.peerDependencies).toStrictEqual({ 'is-positive': '1.0.0' })
})
})
test('publish: runs all the lifecycle scripts', async () => {
await using server = await createTestIpcServer()

View File

@@ -15,6 +15,12 @@
{
"path": "../../__utils__/test-ipc-server"
},
{
"path": "../../catalogs/config"
},
{
"path": "../../catalogs/types"
},
{
"path": "../../cli/cli-utils"
},

View File

@@ -0,0 +1,59 @@
import { InvalidWorkspaceManifestError } from './errors/InvalidWorkspaceManifestError'
export interface WorkspaceNamedCatalogs {
readonly [catalogName: string]: WorkspaceCatalog
}
export interface WorkspaceCatalog {
readonly [dependencyName: string]: string
}
export function assertValidWorkspaceManifestCatalog (manifest: { packages?: readonly string[], catalog?: unknown }): asserts manifest is { catalog?: WorkspaceCatalog } {
if (manifest.catalog == null) {
return
}
if (Array.isArray(manifest.catalog)) {
throw new InvalidWorkspaceManifestError('Expected catalog field to be an object, but found - array')
}
if (typeof manifest.catalog !== 'object') {
throw new InvalidWorkspaceManifestError(`Expected catalog field to be an object, but found - ${typeof manifest.catalog}`)
}
for (const [alias, specifier] of Object.entries(manifest.catalog)) {
if (typeof specifier !== 'string') {
throw new InvalidWorkspaceManifestError(`Invalid catalog entry for ${alias}. Expected string, but found: ${typeof specifier}`)
}
}
}
export function assertValidWorkspaceManifestCatalogs (manifest: { packages?: readonly string[], catalogs?: unknown }): asserts manifest is { catalogs?: WorkspaceNamedCatalogs } {
if (manifest.catalogs == null) {
return
}
if (Array.isArray(manifest.catalogs)) {
throw new InvalidWorkspaceManifestError('Expected catalogs field to be an object, but found - array')
}
if (typeof manifest.catalogs !== 'object') {
throw new InvalidWorkspaceManifestError(`Expected catalogs field to be an object, but found - ${typeof manifest.catalogs}`)
}
for (const [catalogName, catalog] of Object.entries(manifest.catalogs)) {
if (Array.isArray(catalog)) {
throw new InvalidWorkspaceManifestError(`Expected named catalog ${catalogName} to be an object, but found - array`)
}
if (typeof catalog !== 'object') {
throw new InvalidWorkspaceManifestError(`Expected named catalog ${catalogName} to be an object, but found - ${typeof catalog}`)
}
for (const [alias, specifier] of Object.entries(catalog)) {
if (typeof specifier !== 'string') {
throw new InvalidWorkspaceManifestError(`Catalog '${catalogName}' has invalid entry '${alias}'. Expected string specifier, but found: ${typeof specifier}`)
}
}
}
}

View File

@@ -0,0 +1,7 @@
import { PnpmError } from '@pnpm/error'
export class InvalidWorkspaceManifestError extends PnpmError {
constructor (message: string) {
super('INVALID_WORKSPACE_CONFIGURATION', message)
}
}

View File

@@ -1,11 +1,30 @@
import util from 'util'
import { WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import path from 'node:path'
import readYamlFile from 'read-yaml-file'
import {
assertValidWorkspaceManifestCatalog,
assertValidWorkspaceManifestCatalogs,
type WorkspaceCatalog,
type WorkspaceNamedCatalogs,
} from './catalogs'
import { InvalidWorkspaceManifestError } from './errors/InvalidWorkspaceManifestError'
export interface WorkspaceManifest {
packages: string[]
/**
* The default catalog. Package manifests may refer to dependencies in this
* definition through the `catalog:default` specifier or the `catalog:`
* shorthand.
*/
catalog?: WorkspaceCatalog
/**
* A dictionary of named catalogs. Package manifests may refer to dependencies
* in this definition through the `catalog:<name>` specifier.
*/
catalogs?: WorkspaceNamedCatalogs
}
export async function readWorkspaceManifest (dir: string): Promise<WorkspaceManifest | undefined> {
@@ -48,6 +67,9 @@ function validateWorkspaceManifest (manifest: unknown): asserts manifest is Work
}
assertValidWorkspaceManifestPackages(manifest)
assertValidWorkspaceManifestCatalog(manifest)
assertValidWorkspaceManifestCatalogs(manifest)
checkWorkspaceManifestAssignability(manifest)
}
@@ -79,9 +101,3 @@ function assertValidWorkspaceManifestPackages (manifest: { packages?: unknown })
* the future.
*/
function checkWorkspaceManifestAssignability (_manifest: WorkspaceManifest): void {}
class InvalidWorkspaceManifestError extends PnpmError {
constructor (message: string) {
super('INVALID_WORKSPACE_CONFIGURATION', message)
}
}

View File

@@ -0,0 +1,6 @@
packages:
- "packages/**"
- "types"
catalog:
- test

View File

@@ -0,0 +1,5 @@
packages:
- "packages/**"
- "types"
catalog: 5

View File

@@ -0,0 +1,6 @@
packages:
- "packages/**"
- "types"
catalog:
foo: {}

View File

@@ -0,0 +1,6 @@
packages:
- "packages/**"
- "types"
catalog:
foo: ^1.0.0

View File

@@ -0,0 +1,10 @@
packages:
- "packages/**"
- "types"
catalog:
foo: ^1.0.0
catalogs:
default:
bar: ^2.0.0

View File

@@ -0,0 +1,6 @@
packages:
- "packages/**"
- "types"
catalogs:
- foo

View File

@@ -0,0 +1,7 @@
packages:
- "packages/**"
- "types"
catalogs:
foo:
- bar

View File

@@ -0,0 +1,6 @@
packages:
- "packages/**"
- "types"
catalogs:
foo: 92

View File

@@ -0,0 +1,7 @@
packages:
- "packages/**"
- "types"
catalogs:
foo:
bar: {}

View File

@@ -0,0 +1,5 @@
packages:
- "packages/**"
- "types"
catalogs: 5

View File

@@ -0,0 +1,10 @@
packages:
- "packages/**"
- "types"
catalog:
bar: ^1.0.0
catalogs:
foo:
bar: ^2.0.0

View File

@@ -61,4 +61,95 @@ test('readWorkspaceManifest() works when workspace file is null', async () => {
const manifest = await readWorkspaceManifest(path.join(__dirname, '__fixtures__/null'))
expect(manifest).toBeNull()
})
})
describe('readWorkspaceManifest() catalog field', () => {
test('works on simple catalog', async () => {
await expect(readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalog-ok'))).resolves.toEqual({
packages: ['packages/**', 'types'],
catalog: {
foo: '^1.0.0',
},
})
})
test('throws on invalid array', async () => {
await expect(
readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalog-invalid-array'))
).rejects.toThrow('Expected catalog field to be an object, but found - array')
})
test('throws on invalid object', async () => {
await expect(
readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalog-invalid-object'))
).rejects.toThrow('Expected catalog field to be an object, but found - number')
})
test('throws on invalid specifier', async () => {
await expect(
readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalog-invalid-specifier'))
).rejects.toThrow('Invalid catalog entry for foo. Expected string, but found: object')
})
})
describe('readWorkspaceManifest() catalogs field', () => {
test('works with simple named catalogs', async () => {
await expect(readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalogs-ok'))).resolves.toEqual({
packages: ['packages/**', 'types'],
catalog: {
bar: '^1.0.0',
},
catalogs: {
foo: {
bar: '^2.0.0',
},
},
})
})
test('throws on invalid array', async () => {
await expect(
readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalogs-invalid-array'))
).rejects.toThrow('Expected catalogs field to be an object, but found - array')
})
test('throws on invalid value', async () => {
await expect(
readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalogs-invalid-object'))
).rejects.toThrow('Expected catalogs field to be an object, but found - number')
})
test('throws on invalid named catalog array', async () => {
await expect(
readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalogs-invalid-named-catalog-array'))
).rejects.toThrow('Expected named catalog foo to be an object, but found - array')
})
test('throws on invalid named catalog object', async () => {
await expect(
readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalogs-invalid-named-catalog-object'))
).rejects.toThrow('Expected named catalog foo to be an object, but found - number')
})
test('throws on invalid named catalog specifier', async () => {
await expect(
readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalogs-invalid-named-catalog-specifier'))
).rejects.toThrow('Catalog \'foo\' has invalid entry \'bar\'. Expected string specifier, but found: object')
})
})
describe('readWorkspaceManifest() reads default catalog defined alongside named catalogs', () => {
test('works when implicit default catalog is configured alongside named catalogs', async () => {
await expect(readWorkspaceManifest(path.join(__dirname, '__fixtures__/catalogs-ok'))).resolves.toEqual({
packages: ['packages/**', 'types'],
catalog: {
bar: '^1.0.0',
},
catalogs: {
foo: {
bar: '^2.0.0',
},
},
})
})
})