feat: pnpm add option to add new entries to catalogs (#9484)

close #9425

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Khải
2025-05-14 23:32:05 +07:00
committed by GitHub
parent a014bb0e28
commit c8341cca57
26 changed files with 1404 additions and 37 deletions

View File

@@ -0,0 +1,11 @@
---
"@pnpm/plugin-commands-installation": minor
"@pnpm/resolve-dependencies": minor
"@pnpm/workspace.read-manifest": minor
"@pnpm/workspace.manifest-writer": minor
"@pnpm/core": minor
"@pnpm/config": minor
"pnpm": minor
---
Added two new CLI options (`--save-catalog` and `--save-catalog-name=<name>`) to `pnpm add` to save new dependencies as catalog entries. `catalog:` or `catalog:<name>` will be added to `package.json` and the package specifier will be added to the `catalogs` or `catalog[<name>]` object in `pnpm-workspace.yaml` [#9425](https://github.com/pnpm/pnpm/issues/9425).

View File

@@ -52,6 +52,7 @@ export interface Config extends OptionsFromRootManifest {
saveDev?: boolean
saveOptional?: boolean
savePeer?: boolean
saveCatalogName?: string
saveWorkspaceProtocol?: boolean | 'rolling'
lockfileIncludeTarballUrl?: boolean
scriptShell?: string

View File

@@ -175,6 +175,7 @@ export async function getConfig (opts: {
'resolution-mode': 'highest',
'resolve-peers-from-workspace-root': true,
'save-peer': false,
'save-catalog-name': undefined,
'save-workspace-protocol': 'rolling',
'scripts-prepend-node-path': false,
'strict-dep-builds': false,

View File

@@ -88,6 +88,7 @@ export const types = Object.assign({
'aggregate-output': Boolean,
'reporter-hide-prefix': Boolean,
'save-peer': Boolean,
'save-catalog-name': String,
'save-workspace-protocol': Boolean,
'script-shell': String,
'shamefully-flatten': Boolean,

View File

@@ -52,6 +52,7 @@ export interface StrictInstallOptions {
lockfileIncludeTarballUrl: boolean
preferWorkspacePackages: boolean
preserveWorkspaceProtocol: boolean
saveCatalogName?: string
scriptsPrependNodePath: boolean | 'warn-only'
scriptShell?: string
shellEmulator: boolean

View File

@@ -141,12 +141,18 @@ type Opts = Omit<InstallOptions, 'allProjects'> & {
binsDir?: string
} & InstallMutationOptions
export interface InstallResult {
newCatalogs: CatalogSnapshots | undefined
updatedManifest: ProjectManifest
ignoredBuilds: string[] | undefined
}
export async function install (
manifest: ProjectManifest,
opts: Opts
): Promise<{ updatedManifest: ProjectManifest, ignoredBuilds: string[] | undefined }> {
): Promise<InstallResult> {
const rootDir = (opts.dir ?? process.cwd()) as ProjectRootDir
const { updatedProjects: projects, ignoredBuilds } = await mutateModules(
const { newCatalogs, updatedProjects: projects, ignoredBuilds } = await mutateModules(
[
{
mutation: 'install',
@@ -168,7 +174,7 @@ export async function install (
}],
}
)
return { updatedManifest: projects[0].manifest, ignoredBuilds }
return { newCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds }
}
interface ProjectToBeInstalled {
@@ -188,6 +194,12 @@ export type MutateModulesOptions = InstallOptions & {
} | InstallOptions['hooks']
}
export interface MutateModulesInSingleProjectResult {
newCatalogs: CatalogSnapshots | undefined
updatedProject: UpdatedProject
ignoredBuilds: string[] | undefined
}
export async function mutateModulesInSingleProject (
project: MutatedProject & {
binsDir?: string
@@ -196,7 +208,7 @@ export async function mutateModulesInSingleProject (
modulesDir?: string
},
maybeOpts: Omit<MutateModulesOptions, 'allProjects'> & InstallMutationOptions
): Promise<{ updatedProject: UpdatedProject, ignoredBuilds: string[] | undefined }> {
): Promise<MutateModulesInSingleProjectResult> {
const result = await mutateModules(
[
{
@@ -215,10 +227,15 @@ export async function mutateModulesInSingleProject (
}],
}
)
return { updatedProject: result.updatedProjects[0], ignoredBuilds: result.ignoredBuilds }
return {
newCatalogs: result.newCatalogs,
updatedProject: result.updatedProjects[0],
ignoredBuilds: result.ignoredBuilds,
}
}
export interface MutateModulesResult {
newCatalogs?: CatalogSnapshots
updatedProjects: UpdatedProject[]
stats: InstallationResultStats
depsRequiringBuild?: DepPath[]
@@ -315,6 +332,7 @@ export async function mutateModules (
}
return {
newCatalogs: result.newCatalogs,
updatedProjects: result.updatedProjects,
stats: result.stats ?? { added: 0, removed: 0, linkedToRoot: 0 },
depsRequiringBuild: result.depsRequiringBuild,
@@ -322,6 +340,7 @@ export async function mutateModules (
}
interface InnerInstallResult {
readonly newCatalogs?: CatalogSnapshots
readonly updatedProjects: UpdatedProject[]
readonly stats?: InstallationResultStats
readonly depsRequiringBuild?: DepPath[]
@@ -520,6 +539,7 @@ export async function mutateModules (
optionalDependencies,
updateWorkspaceDependencies: project.update,
preferredSpecs,
saveCatalogName: opts.saveCatalogName,
overrides: opts.overrides,
defaultCatalog: opts.catalogs?.default,
})
@@ -548,6 +568,7 @@ export async function mutateModules (
})
return {
newCatalogs: result.newCatalogs,
updatedProjects: result.projects,
stats: result.stats,
depsRequiringBuild: result.depsRequiringBuild,
@@ -854,9 +875,9 @@ export async function addDependenciesToPackage (
pinnedVersion?: 'major' | 'minor' | 'patch'
targetDependenciesField?: DependenciesField
} & InstallMutationOptions
): Promise<{ updatedManifest: ProjectManifest, ignoredBuilds: string[] | undefined }> {
): Promise<InstallResult> {
const rootDir = (opts.dir ?? process.cwd()) as ProjectRootDir
const { updatedProjects: projects, ignoredBuilds } = await mutateModules(
const { newCatalogs, updatedProjects: projects, ignoredBuilds } = await mutateModules(
[
{
allowNew: opts.allowNew,
@@ -884,7 +905,7 @@ export async function addDependenciesToPackage (
},
],
})
return { updatedManifest: projects[0].manifest, ignoredBuilds }
return { newCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds }
}
export type ImporterToUpdate = {
@@ -909,6 +930,7 @@ export interface UpdatedProject {
}
interface InstallFunctionResult {
newCatalogs?: CatalogSnapshots
newLockfile: LockfileObject
projects: UpdatedProject[]
stats?: InstallationResultStats
@@ -1023,6 +1045,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
dependenciesGraph,
dependenciesByProjectId,
linkedDependenciesByProjectId,
newCatalogs,
newLockfile,
outdatedDependencies,
peerDependencyIssuesByProjects,
@@ -1417,6 +1440,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
}
return {
newCatalogs,
newLockfile,
projects: projects.map(({ id, manifest, rootDir }) => ({
manifest,

View File

@@ -16,6 +16,7 @@ export function parseWantedDependencies (
overrides?: Record<string, string>
updateWorkspaceDependencies?: boolean
preferredSpecs?: Record<string, string>
saveCatalogName?: string
defaultCatalog?: Catalog
}
): WantedDependency[] {
@@ -43,7 +44,8 @@ export function parseWantedDependencies (
dev: Boolean(opts.dev || alias && !!opts.devDependencies[alias]),
optional: Boolean(opts.optional || alias && !!opts.optionalDependencies[alias]),
prevSpecifier: alias && opts.currentBareSpecifiers[alias],
}
saveCatalogName: opts.saveCatalogName,
} satisfies Partial<WantedDependency>
if (bareSpecifier) {
return {
...result,

View File

@@ -66,6 +66,7 @@
"@pnpm/store-connection-manager": "workspace:*",
"@pnpm/types": "workspace:*",
"@pnpm/workspace.find-packages": "workspace:*",
"@pnpm/workspace.manifest-writer": "workspace:*",
"@pnpm/workspace.pkgs-graph": "workspace:*",
"@pnpm/workspace.state": "workspace:*",
"@pnpm/write-project-manifest": "workspace:*",

View File

@@ -11,6 +11,10 @@ import { type InstallCommandOptions } from './install'
import { installDeps } from './installDeps'
import { writeSettings } from '@pnpm/config.config-writer'
export const shorthands: Record<string, string> = {
'save-catalog': '--save-catalog-name=default',
}
export function rcOptionsTypes (): Record<string, unknown> {
return pick([
'cache-dir',
@@ -51,6 +55,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
'public-hoist-pattern',
'registry',
'reporter',
'save-catalog-name',
'save-dev',
'save-exact',
'save-optional',
@@ -116,6 +121,14 @@ export function help (): string {
description: 'Save package to your `peerDependencies` and `devDependencies`',
name: '--save-peer',
},
{
description: 'Save package to the default catalog',
name: '--save-catalog',
},
{
description: 'Save package to the specified catalog',
name: '--save-catalog-name=<name>',
},
{
description: 'Install exact version',
name: '--[no-]save-exact',

View File

@@ -297,6 +297,7 @@ export type InstallCommandOptions = Pick<Config,
| 'savePeer'
| 'savePrefix'
| 'saveProd'
| 'saveCatalogName'
| 'saveWorkspaceProtocol'
| 'lockfileIncludeTarballUrl'
| 'allProjectsGraph'

View File

@@ -22,6 +22,7 @@ import {
} from '@pnpm/core'
import { globalInfo, logger } from '@pnpm/logger'
import { sequenceGraph } from '@pnpm/sort-packages'
import { addCatalogs } from '@pnpm/workspace.manifest-writer'
import { createPkgGraph } from '@pnpm/workspace.pkgs-graph'
import { updateWorkspaceState, type WorkspaceStateSettings } from '@pnpm/workspace.state'
import isSubdir from 'is-subdir'
@@ -313,9 +314,12 @@ when running add/update with the --workspace option')
rootDir: opts.dir as ProjectRootDir,
targetDependenciesField: getSaveType(opts),
}
const { updatedProject, ignoredBuilds } = await mutateModulesInSingleProject(mutatedProject, installOpts)
const { newCatalogs, updatedProject, ignoredBuilds } = await mutateModulesInSingleProject(mutatedProject, installOpts)
if (opts.save !== false) {
await writeProjectManifest(updatedProject.manifest)
await Promise.all([
writeProjectManifest(updatedProject.manifest),
newCatalogs && addCatalogs(opts.workspaceDir ?? opts.dir, newCatalogs),
])
}
if (!opts.lockfileOnly) {
await updateWorkspaceState({
@@ -333,9 +337,12 @@ when running add/update with the --workspace option')
return
}
const { updatedManifest, ignoredBuilds } = await install(manifest, installOpts)
const { newCatalogs, updatedManifest, ignoredBuilds } = await install(manifest, installOpts)
if (opts.update === true && opts.save !== false) {
await writeProjectManifest(updatedManifest)
await Promise.all([
writeProjectManifest(updatedManifest),
newCatalogs && addCatalogs(opts.workspaceDir ?? opts.dir, newCatalogs),
])
}
if (opts.strictDepBuilds && ignoredBuilds?.length) {
throw new IgnoredBuildsError(ignoredBuilds)

View File

@@ -13,6 +13,7 @@ import {
} from '@pnpm/config'
import { PnpmError } from '@pnpm/error'
import { arrayOfWorkspacePackagesToMap } from '@pnpm/get-context'
import { type CatalogSnapshots } from '@pnpm/lockfile.types'
import { logger } from '@pnpm/logger'
import { filterDependenciesByType } from '@pnpm/manifest-utils'
import { createMatcherWithIndex } from '@pnpm/matcher'
@@ -30,6 +31,7 @@ import {
type ProjectRootDir,
type ProjectRootDirRealPath,
} from '@pnpm/types'
import { addCatalogs } from '@pnpm/workspace.manifest-writer'
import {
addDependenciesToPackage,
install,
@@ -70,6 +72,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'rootProjectManifest'
| 'rootProjectManifestDir'
| 'save'
| 'saveCatalogName'
| 'saveDev'
| 'saveExact'
| 'saveOptional'
@@ -149,6 +152,7 @@ export async function recursive (
pruneLockfileImporters: opts.pruneLockfileImporters ??
(((opts.ignoredPackages == null) || opts.ignoredPackages.size === 0) &&
pkgs.length === allProjects.length),
saveCatalogName: opts.saveCatalogName,
storeController: store.ctrl,
storeDir: store.dir,
targetDependenciesField,
@@ -275,17 +279,22 @@ export async function recursive (
throw new PnpmError('NO_PACKAGE_IN_DEPENDENCIES',
'None of the specified packages were found in the dependencies of any of the projects.')
}
const { updatedProjects: mutatedPkgs, ignoredBuilds } = await mutateModules(mutatedImporters, {
const {
newCatalogs,
updatedProjects: mutatedPkgs,
ignoredBuilds,
} = await mutateModules(mutatedImporters, {
...installOpts,
storeController: store.ctrl,
})
if (opts.save !== false) {
await Promise.all(
mutatedPkgs
.map(async ({ originalManifest, manifest, rootDir }) => {
return manifestsByPath[rootDir].writeProjectManifest(originalManifest ?? manifest)
})
)
const promises: Array<Promise<void>> = mutatedPkgs.map(async ({ originalManifest, manifest, rootDir }) => {
return manifestsByPath[rootDir].writeProjectManifest(originalManifest ?? manifest)
})
if (newCatalogs) {
promises.push(addCatalogs(opts.workspaceDir, newCatalogs))
}
await Promise.all(promises)
}
if (opts.strictDepBuilds && ignoredBuilds?.length) {
throw new IgnoredBuildsError(ignoredBuilds)
@@ -295,6 +304,8 @@ export async function recursive (
const pkgPaths = (Object.keys(opts.selectedProjectsGraph) as ProjectRootDir[]).sort()
let newCatalogs: CatalogSnapshots | undefined
const limitInstallation = pLimit(getWorkspaceConcurrency(opts.workspaceConcurrency))
await Promise.all(pkgPaths.map(async (rootDir) =>
limitInstallation(async () => {
@@ -339,6 +350,7 @@ export async function recursive (
& { pinnedVersion: 'major' | 'minor' | 'patch' }
interface ActionResult {
newCatalogs?: CatalogSnapshots
updatedManifest: ProjectManifest
ignoredBuilds: string[] | undefined
}
@@ -356,7 +368,11 @@ export async function recursive (
rootDir,
},
], opts)
return { updatedManifest: mutationResult.updatedProjects[0].manifest, ignoredBuilds: mutationResult.ignoredBuilds }
return {
newCatalogs: undefined, // there's no reason to add new catalogs on `pnpm remove`
updatedManifest: mutationResult.updatedProjects[0].manifest,
ignoredBuilds: mutationResult.ignoredBuilds,
}
}
break
default:
@@ -367,7 +383,11 @@ export async function recursive (
}
const localConfig = await memReadLocalConfig(rootDir)
const { updatedManifest: newManifest, ignoredBuilds } = await action(
const {
newCatalogs: newCatalogsAddition,
updatedManifest: newManifest,
ignoredBuilds,
} = await action(
manifest,
{
...installOpts,
@@ -391,6 +411,10 @@ export async function recursive (
)
if (opts.save !== false) {
await writeProjectManifest(newManifest)
if (newCatalogsAddition) {
newCatalogs ??= {}
Object.assign(newCatalogs, newCatalogsAddition)
}
}
if (opts.strictDepBuilds && ignoredBuilds?.length) {
throw new IgnoredBuildsError(ignoredBuilds)
@@ -415,6 +439,10 @@ export async function recursive (
})
))
if (newCatalogs) {
await addCatalogs(opts.workspaceDir, newCatalogs)
}
if (
!opts.lockfileOnly && !opts.ignoreScripts && (
cmdFullName === 'add' ||

View File

@@ -0,0 +1,210 @@
import fs from 'fs'
import path from 'path'
import { add } from '@pnpm/plugin-commands-installation'
import { prepare, preparePackages } from '@pnpm/prepare'
import { addDistTag } from '@pnpm/registry-mock'
import { type LockfileFile } from '@pnpm/lockfile.types'
import { sync as loadJsonFile } from 'load-json-file'
import { sync as readYamlFile } from 'read-yaml-file'
import { DEFAULT_OPTS } from './utils'
// This must be a function because some of its values depend on CWD
const createOptions = (saveCatalogName = 'default'): add.AddCommandOptions => ({
...DEFAULT_OPTS,
saveCatalogName,
dir: process.cwd(),
cacheDir: path.resolve('cache'),
storeDir: path.resolve('store'),
})
test('saveCatalogName creates new workspace manifest with the new catalogs', async () => {
const project = prepare({
name: 'test-save-catalog',
version: '0.0.0',
private: true,
})
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
await add.handler(createOptions(), ['@pnpm.e2e/foo'])
expect(loadJsonFile('package.json')).toHaveProperty(['dependencies'], {
'@pnpm.e2e/foo': 'catalog:',
})
expect(readYamlFile('pnpm-workspace.yaml')).toHaveProperty(['catalog'], {
'@pnpm.e2e/foo': '^100.1.0',
})
expect(project.readLockfile()).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/foo': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/foo': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
},
packages: {
'@pnpm.e2e/foo@100.1.0': {
resolution: expect.anything(),
},
},
} as Partial<LockfileFile>))
})
test('saveCatalogName works with different protocols', async () => {
const project = prepare({
name: 'test-save-catalog',
version: '0.0.0',
private: true,
})
const options = createOptions()
options.registries['@jsr'] = options.rawConfig['@jsr:registry'] = 'https://npm.jsr.io/'
await add.handler(options, [
'@pnpm.e2e/foo@100.1.0',
'jsr:@rus/greet@0.0.3',
'github:kevva/is-positive#97edff6',
])
expect(loadJsonFile('package.json')).toHaveProperty(['dependencies'], {
'@pnpm.e2e/foo': 'catalog:',
'@rus/greet': 'catalog:',
'is-positive': 'catalog:',
})
expect(readYamlFile('pnpm-workspace.yaml')).toHaveProperty(['catalog'], {
'@pnpm.e2e/foo': '100.1.0',
'@rus/greet': 'jsr:0.0.3',
'is-positive': 'github:kevva/is-positive#97edff6',
})
expect(project.readLockfile()).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/foo': {
specifier: '100.1.0',
version: '100.1.0',
},
'@rus/greet': {
specifier: 'jsr:0.0.3',
version: '0.0.3',
},
'is-positive': {
specifier: 'github:kevva/is-positive#97edff6',
version: '3.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/foo': {
specifier: 'catalog:',
version: '100.1.0',
},
'@rus/greet': {
specifier: 'catalog:',
version: '@jsr/rus__greet@0.0.3',
},
'is-positive': {
specifier: 'catalog:',
version: 'https://codeload.github.com/kevva/is-positive/tar.gz/97edff6',
},
},
},
},
} as Partial<LockfileFile>))
})
test('saveCatalogName does not work with local dependencies', async () => {
preparePackages([
{
name: 'local-dep',
version: '0.1.2-local',
private: true,
},
{
name: 'main',
version: '0.0.0',
private: true,
},
])
process.chdir('main')
await add.handler(createOptions(), ['../local-dep'])
expect(loadJsonFile('package.json')).toStrictEqual({
name: 'main',
version: '0.0.0',
private: true,
dependencies: {
'local-dep': process.platform === 'win32'
? 'link:..\\local-dep'
: 'link:../local-dep',
},
})
expect(fs.existsSync('pnpm-workspace.yaml')).toBe(false)
expect(readYamlFile('pnpm-lock.yaml')).not.toHaveProperty(['catalog'])
expect(readYamlFile('pnpm-lock.yaml')).not.toHaveProperty(['catalogs'])
})
test('saveCatalogName with non-default name', async () => {
const project = prepare({
name: 'test-save-catalog',
version: '0.0.0',
private: true,
})
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
await add.handler(createOptions('my-catalog'), ['@pnpm.e2e/foo'])
expect(loadJsonFile('package.json')).toHaveProperty(['dependencies'], {
'@pnpm.e2e/foo': 'catalog:my-catalog',
})
expect(readYamlFile('pnpm-workspace.yaml')).toHaveProperty(['catalogs', 'my-catalog'], {
'@pnpm.e2e/foo': '^100.1.0',
})
expect(project.readLockfile()).toStrictEqual(expect.objectContaining({
catalogs: {
'my-catalog': {
'@pnpm.e2e/foo': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/foo': {
specifier: 'catalog:my-catalog',
version: '100.1.0',
},
},
},
},
packages: {
'@pnpm.e2e/foo@100.1.0': {
resolution: expect.anything(),
},
},
} as Partial<LockfileFile>))
})

View File

@@ -120,6 +120,9 @@
{
"path": "../../workspace/find-workspace-dir"
},
{
"path": "../../workspace/manifest-writer"
},
{
"path": "../../workspace/pkgs-graph"
},

View File

@@ -7,6 +7,7 @@ export interface WantedDependency {
dev: boolean
optional: boolean
injected?: boolean
saveCatalogName?: string
}
type GetNonDevWantedDependenciesManifest = Pick<DependencyManifest, 'bundleDependencies' | 'bundledDependencies' | 'optionalDependencies' | 'dependencies' | 'dependenciesMeta'>

View File

@@ -12,6 +12,7 @@ export interface WantedDependency {
dev: boolean
optional: boolean
nodeExecPath?: string
saveCatalogName?: string
updateSpec?: boolean
prevSpecifier?: string
}

View File

@@ -3,6 +3,7 @@ import {
packageManifestLogger,
} from '@pnpm/core-loggers'
import {
type CatalogSnapshots,
type LockfileObject,
type ProjectSnapshot,
} from '@pnpm/lockfile.types'
@@ -93,6 +94,7 @@ export interface ImporterToResolve extends Importer<{
export interface ResolveDependenciesResult {
dependenciesByProjectId: DependenciesByProjectId
dependenciesGraph: GenericDependenciesGraphWithResolvedChildren<ResolvedPackage>
newCatalogs?: CatalogSnapshots | undefined
outdatedDependencies: {
[pkgId: string]: string
}
@@ -135,6 +137,7 @@ export async function resolveDependencies (
appliedPatches,
time,
allPeerDepNames,
newCatalogs,
} = await resolveDependencyTree(projectsToResolve, opts)
opts.storeController.clearResolutionCache()
@@ -303,7 +306,17 @@ export async function resolveDependencies (
}
}
// Q: Why would `newLockfile.catalogs` be constructed twice?
// A: `getCatalogSnapshots` handles new dependencies that were resolved as `catalog:*` (e.g. new entries in `package.json` whose values were `catalog:*`),
// and `newCatalogs` handles dependencies that were added as CLI parameters from `pnpm add --save-catalog`.
newLockfile.catalogs = getCatalogSnapshots(Object.values(resolvedImporters).flatMap(({ directDependencies }) => directDependencies))
for (const catalogName in newCatalogs) {
for (const dependencyName in newCatalogs[catalogName]) {
newLockfile.catalogs ??= {}
newLockfile.catalogs[catalogName] ??= {}
newLockfile.catalogs[catalogName][dependencyName] = newCatalogs[catalogName][dependencyName]
}
}
// waiting till package requests are finished
async function waitTillAllFetchingsFinish (): Promise<void> {
@@ -319,6 +332,7 @@ export async function resolveDependencies (
dependenciesGraph,
outdatedDependencies,
linkedDependenciesByProjectId,
newCatalogs,
newLockfile,
peerDependencyIssuesByProjects,
waitTillAllFetchingsFinish,

View File

@@ -204,6 +204,7 @@ export type PkgAddress = {
catalogLookup?: CatalogLookupMetadata
optional: boolean
normalizedBareSpecifier?: string
saveCatalogName?: string
} & ({
isLinkedDependency: true
version: string
@@ -1565,6 +1566,9 @@ async function resolveDependency (
}
}
}
const resolvedPkg = ctx.resolvedPkgsById[pkgResponse.body.id]
return {
alias: wantedDependency.alias ?? pkgResponse.body.alias ?? pkg.name,
depIsLinked,
@@ -1576,7 +1580,9 @@ async function resolveDependency (
pkgId: pkgResponse.body.id,
rootDir,
missingPeers: getMissingPeers(pkg),
optional: ctx.resolvedPkgsById[pkgResponse.body.id].optional,
optional: resolvedPkg.optional,
version: resolvedPkg.version,
saveCatalogName: wantedDependency.saveCatalogName,
// Next fields are actually only needed when isNew = true
installable,

View File

@@ -1,6 +1,7 @@
import { resolveFromCatalog } from '@pnpm/catalogs.resolver'
import { type Catalogs } from '@pnpm/catalogs.types'
import { type LockfileObject } from '@pnpm/lockfile.types'
import { type CatalogSnapshots, type LockfileObject } from '@pnpm/lockfile.types'
import { globalWarn } from '@pnpm/logger'
import { type PatchGroupRecord } from '@pnpm/patching.config'
import { type PreferredVersions, type Resolution, type WorkspacePackages } from '@pnpm/resolver-base'
import { type StoreController } from '@pnpm/store-controller-types'
@@ -136,6 +137,7 @@ export interface ResolveDependenciesOptions {
export interface ResolveDependencyTreeResult {
allPeerDepNames: Set<string>
dependenciesTree: DependenciesTree<ResolvedPackage>
newCatalogs?: CatalogSnapshots
outdatedDependencies: {
[pkgId: string]: string
}
@@ -234,6 +236,29 @@ export async function resolveDependencyTree<T> (
const { pkgAddressesByImporters, time } = await resolveRootDependencies(ctx, resolveArgs)
const directDepsByImporterId = zipObj(importers.map(({ id }) => id), pkgAddressesByImporters)
let newCatalogs: CatalogSnapshots | undefined
for (const directDependencies of pkgAddressesByImporters) {
for (const directDep of directDependencies as PkgAddress[]) {
const { alias, normalizedBareSpecifier, version, saveCatalogName } = directDep
const existingCatalog = opts.catalogs?.default?.[alias]
if (existingCatalog != null) {
if (existingCatalog !== normalizedBareSpecifier) {
globalWarn(
`Skip adding ${alias} to catalogs.${saveCatalogName} because it already exists as ${existingCatalog}`
)
}
} else if (saveCatalogName != null && normalizedBareSpecifier != null && version != null) {
newCatalogs ??= {}
newCatalogs[saveCatalogName] ??= {}
newCatalogs[saveCatalogName][alias] = {
specifier: normalizedBareSpecifier,
version,
}
directDep.normalizedBareSpecifier = `catalog:${saveCatalogName === 'default' ? '' : saveCatalogName}`
}
}
}
for (const pendingNode of ctx.pendingNodes) {
ctx.dependenciesTree.set(pendingNode.nodeId, {
children: () => buildTree(ctx, pendingNode.resolvedPackage.id,
@@ -276,6 +301,7 @@ export async function resolveDependencyTree<T> (
return {
dependenciesTree: ctx.dependenciesTree,
newCatalogs,
outdatedDependencies: ctx.outdatedDependencies,
resolvedImporters,
resolvedPkgsById: ctx.resolvedPkgsById,

6
pnpm-lock.yaml generated
View File

@@ -5472,6 +5472,9 @@ importers:
'@pnpm/workspace.find-packages':
specifier: workspace:*
version: link:../../workspace/find-packages
'@pnpm/workspace.manifest-writer':
specifier: workspace:*
version: link:../../workspace/manifest-writer
'@pnpm/workspace.pkgs-graph':
specifier: workspace:*
version: link:../../workspace/pkgs-graph
@@ -8273,6 +8276,9 @@ importers:
'@pnpm/constants':
specifier: workspace:*
version: link:../../packages/constants
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/object.key-sorting':
specifier: workspace:*
version: link:../../object/key-sorting

768
pnpm/test/saveCatalog.ts Normal file
View File

@@ -0,0 +1,768 @@
import { type LockfileFile } from '@pnpm/lockfile.types'
import { prepare, preparePackages } from '@pnpm/prepare'
import { addDistTag } from '@pnpm/registry-mock'
import { type ProjectManifest } from '@pnpm/types'
import { sync as loadJsonFile } from 'load-json-file'
import { sync as readYamlFile } from 'read-yaml-file'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpm } from './utils'
test('--save-catalog adds catalogs to the manifest of a single package workspace', async () => {
const manifest: ProjectManifest = {
name: 'test-save-catalog',
version: '0.0.0',
private: true,
dependencies: {
'@pnpm.e2e/bar': 'catalog:',
},
}
prepare(manifest)
writeYamlFile('pnpm-workspace.yaml', {
catalog: {
'@pnpm.e2e/bar': '^100.1.0',
},
})
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
await addDistTag({ package: '@pnpm.e2e/bar', version: '100.1.0', distTag: 'latest' })
await execPnpm(['install'])
expect(readYamlFile('pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/bar': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
},
packages: {
'@pnpm.e2e/bar@100.1.0': expect.anything(),
},
} as Partial<LockfileFile>))
await execPnpm(['add', '--save-catalog', '@pnpm.e2e/foo'])
expect(readYamlFile('pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/bar': {
specifier: '^100.1.0',
version: '100.1.0',
},
'@pnpm.e2e/foo': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.1.0',
},
'@pnpm.e2e/foo': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
},
packages: {
'@pnpm.e2e/bar@100.1.0': expect.anything(),
'@pnpm.e2e/foo@100.1.0': expect.anything(),
},
} as Partial<LockfileFile>))
expect(readYamlFile('pnpm-workspace.yaml')).toStrictEqual({
catalog: {
'@pnpm.e2e/bar': '^100.1.0',
'@pnpm.e2e/foo': '^100.1.0',
},
})
expect(loadJsonFile('package.json')).toStrictEqual({
...manifest,
dependencies: {
...manifest.dependencies,
'@pnpm.e2e/foo': 'catalog:',
},
})
})
test('--save-catalog adds catalogs to the manifest of a shared lockfile workspace', async () => {
const manifests: ProjectManifest[] = [
{
name: 'project-0',
version: '0.0.0',
dependencies: {
'@pnpm.e2e/bar': 'catalog:',
},
},
{
name: 'project-1',
version: '0.0.0',
},
]
preparePackages(manifests)
writeYamlFile('pnpm-workspace.yaml', {
sharedWorkspaceLockfile: true,
catalog: {
'@pnpm.e2e/bar': '^100.1.0',
},
packages: ['project-0', 'project-1'],
})
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
await addDistTag({ package: '@pnpm.e2e/bar', version: '100.1.0', distTag: 'latest' })
await execPnpm(['install'])
expect(readYamlFile('pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/bar': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
},
importers: {
'project-0': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
'project-1': {},
},
packages: {
'@pnpm.e2e/bar@100.1.0': expect.anything(),
},
} as Partial<LockfileFile>))
await execPnpm(['--filter=project-1', 'add', '--save-catalog', '@pnpm.e2e/foo'])
expect(readYamlFile('pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/bar': {
specifier: '^100.1.0',
version: '100.1.0',
},
'@pnpm.e2e/foo': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
},
importers: {
'project-0': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
'project-1': {
dependencies: {
'@pnpm.e2e/foo': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
},
packages: {
'@pnpm.e2e/bar@100.1.0': expect.anything(),
'@pnpm.e2e/foo@100.1.0': expect.anything(),
},
} as Partial<LockfileFile>))
expect(readYamlFile('pnpm-workspace.yaml')).toStrictEqual({
catalog: {
'@pnpm.e2e/bar': '^100.1.0',
'@pnpm.e2e/foo': '^100.1.0',
},
packages: ['project-0', 'project-1'],
sharedWorkspaceLockfile: true,
})
expect(loadJsonFile('project-1/package.json')).toStrictEqual({
...manifests[1],
dependencies: {
'@pnpm.e2e/foo': 'catalog:',
},
})
})
test('--save-catalog adds catalogs to the manifest of a multi-lockfile workspace', async () => {
const manifests: ProjectManifest[] = [
{
name: 'project-0',
version: '0.0.0',
dependencies: {
'@pnpm.e2e/bar': 'catalog:',
},
},
{
name: 'project-1',
version: '0.0.0',
},
]
preparePackages(manifests)
writeYamlFile('pnpm-workspace.yaml', {
sharedWorkspaceLockfile: false,
catalog: {
'@pnpm.e2e/bar': '^100.1.0',
},
packages: ['project-0', 'project-1'],
})
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
await addDistTag({ package: '@pnpm.e2e/bar', version: '100.1.0', distTag: 'latest' })
{
await execPnpm(['install'])
const lockfile0: LockfileFile = readYamlFile('project-0/pnpm-lock.yaml')
expect(lockfile0.catalogs).toStrictEqual({
default: {
'@pnpm.e2e/bar': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
} as LockfileFile['catalogs'])
expect(lockfile0.importers).toStrictEqual({
'.': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
} as LockfileFile['importers'])
const lockfile1: LockfileFile = readYamlFile('project-1/pnpm-lock.yaml')
expect(lockfile1.catalogs).toBeUndefined()
expect(lockfile1.importers).toStrictEqual({
'.': {},
} as LockfileFile['importers'])
}
{
await execPnpm(['--filter=project-1', 'add', '--save-catalog', '@pnpm.e2e/foo'])
const lockfile0: LockfileFile = readYamlFile('project-0/pnpm-lock.yaml')
expect(lockfile0.catalogs).toStrictEqual({
default: {
'@pnpm.e2e/bar': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
} as LockfileFile['catalogs'])
expect(lockfile0.importers).toStrictEqual({
'.': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
} as LockfileFile['importers'])
const lockfile1: LockfileFile = readYamlFile('project-1/pnpm-lock.yaml')
expect(lockfile1.catalogs).toStrictEqual({
default: {
'@pnpm.e2e/foo': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
} as LockfileFile['catalogs'])
expect(lockfile1.importers).toStrictEqual({
'.': {
dependencies: {
'@pnpm.e2e/foo': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
} as LockfileFile['importers'])
expect(readYamlFile('pnpm-workspace.yaml')).toStrictEqual({
catalog: {
'@pnpm.e2e/bar': '^100.1.0',
'@pnpm.e2e/foo': '^100.1.0',
},
packages: ['project-0', 'project-1'],
sharedWorkspaceLockfile: false,
})
expect(loadJsonFile('project-1/package.json')).toStrictEqual({
...manifests[1],
dependencies: {
...manifests[1].dependencies,
'@pnpm.e2e/foo': 'catalog:',
},
})
}
})
test('--save-catalog does not add local workspace dependency as a catalog', async () => {
const manifests: ProjectManifest[] = [
{
name: 'project-0',
version: '0.0.0',
},
{
name: 'project-1',
version: '0.0.0',
},
]
preparePackages(manifests)
writeYamlFile('pnpm-workspace.yaml', {
packages: ['project-0', 'project-1'],
})
{
await execPnpm(['install'])
const lockfile: LockfileFile = readYamlFile('pnpm-lock.yaml')
expect(lockfile.catalogs).toBeUndefined()
expect(lockfile.importers).toStrictEqual({
'project-0': {},
'project-1': {},
})
}
{
await execPnpm(['--filter=project-1', 'add', '--save-catalog', 'project-0@workspace:*'])
const lockfile: LockfileFile = readYamlFile('pnpm-lock.yaml')
expect(lockfile.catalogs).toBeUndefined()
expect(lockfile.importers).toStrictEqual({
'project-0': {},
'project-1': {
dependencies: {
'project-0': {
specifier: 'workspace:*',
version: 'link:../project-0',
},
},
},
})
expect(readYamlFile('pnpm-workspace.yaml')).toStrictEqual({
packages: ['project-0', 'project-1'],
})
expect(loadJsonFile('project-1/package.json')).toStrictEqual({
...manifests[1],
dependencies: {
'project-0': 'workspace:*',
},
})
}
})
test('--save-catalog does not affect new dependencies from package.json', async () => {
const manifest: ProjectManifest = {
name: 'test-save-catalog',
version: '0.0.0',
private: true,
dependencies: {
'@pnpm.e2e/pkg-a': 'catalog:',
},
}
const project = prepare(manifest)
writeYamlFile('pnpm-workspace.yaml', {
catalog: {
'@pnpm.e2e/pkg-a': '1.0.0',
},
})
// initialize the lockfile
await execPnpm(['install'])
expect(project.readLockfile()).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/pkg-a': {
specifier: '1.0.0',
version: '1.0.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/pkg-a': {
specifier: 'catalog:',
version: '1.0.0',
},
},
},
},
} as Partial<LockfileFile>))
// add a new dependency to package.json by editing it
project.writePackageJson({
...manifest,
dependencies: {
...manifest.dependencies,
'@pnpm.e2e/pkg-b': '*',
},
} as ProjectManifest)
// add a new dependency by running `pnpm add --save-catalog`
await execPnpm(['add', '--save-catalog', '@pnpm.e2e/pkg-c'])
const lockfile = project.readLockfile()
expect(lockfile.catalogs).toStrictEqual({
default: {
'@pnpm.e2e/pkg-a': {
specifier: '1.0.0',
version: '1.0.0',
},
'@pnpm.e2e/pkg-c': {
specifier: '^1.0.0',
version: '1.0.0',
},
},
} as LockfileFile['catalogs'])
expect(lockfile.catalogs.default).not.toHaveProperty(['@pnpm.e2e/pkg-b'])
expect(lockfile.importers).toStrictEqual({
'.': {
dependencies: {
'@pnpm.e2e/pkg-a': {
specifier: 'catalog:',
version: '1.0.0',
},
'@pnpm.e2e/pkg-b': {
specifier: '*', // unaffected by `pnpm add --save-catalog`
version: '1.0.0',
},
'@pnpm.e2e/pkg-c': {
specifier: 'catalog:', // created by `pnpm add --save-catalog`
version: '1.0.0',
},
},
},
} as LockfileFile['importers'])
expect(loadJsonFile('package.json')).toStrictEqual({
...manifest,
dependencies: {
...manifest.dependencies,
'@pnpm.e2e/pkg-b': '*', // unaffected by `pnpm add --save-catalog`
'@pnpm.e2e/pkg-c': 'catalog:', // created by `pnpm add --save-catalog`
},
} as ProjectManifest)
})
test('--save-catalog does not overwrite existing catalogs', async () => {
const manifests: ProjectManifest[] = [
{
name: 'project-0',
version: '0.0.0',
dependencies: {
'@pnpm.e2e/bar': 'catalog:',
},
},
{
name: 'project-1',
version: '0.0.0',
},
]
preparePackages(manifests)
writeYamlFile('pnpm-workspace.yaml', {
catalog: {
'@pnpm.e2e/bar': '=100.0.0', // intentionally outdated
},
packages: ['project-0', 'project-1'],
})
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
await addDistTag({ package: '@pnpm.e2e/bar', version: '100.1.0', distTag: 'latest' })
await execPnpm(['install'])
expect(readYamlFile('pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/bar': {
specifier: '=100.0.0',
version: '100.0.0',
},
},
},
importers: {
'project-0': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.0.0',
},
},
},
'project-1': {},
},
} as Partial<LockfileFile>))
await execPnpm(['add', '--filter=project-1', '--save-catalog', '@pnpm.e2e/foo@100.1.0', '@pnpm.e2e/bar@100.1.0'])
expect(readYamlFile('pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/bar': {
specifier: '=100.0.0', // unchanged
version: '100.0.0',
},
'@pnpm.e2e/foo': {
specifier: '100.1.0', // created by `pnpm add --save-catalog`
version: '100.1.0',
},
},
},
importers: {
'project-0': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:', // unchanged
version: '100.0.0',
},
},
},
'project-1': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: '100.1.0', // created by `pnpm add --save-catalog`
version: '100.1.0',
},
'@pnpm.e2e/foo': {
specifier: 'catalog:', // created by `pnpm add --save-catalog`
version: '100.1.0',
},
},
},
},
} as Partial<LockfileFile>))
expect(readYamlFile('pnpm-workspace.yaml')).toStrictEqual({
catalog: {
'@pnpm.e2e/bar': '=100.0.0', // unchanged
'@pnpm.e2e/foo': '100.1.0', // created by `pnpm add --save-catalog`
},
packages: ['project-0', 'project-1'],
})
expect(loadJsonFile('project-0/package.json')).toStrictEqual(manifests[0])
expect(loadJsonFile('project-1/package.json')).toStrictEqual({
...manifests[1],
dependencies: {
...manifests[1].dependencies,
'@pnpm.e2e/bar': '100.1.0',
'@pnpm.e2e/foo': 'catalog:',
},
} as ProjectManifest)
})
test('--save-catalog creates new workspace manifest with the new catalog (recursive add)', async () => {
const manifests: ProjectManifest[] = [
{
name: 'project-0',
version: '0.0.0',
},
{
name: 'project-1',
version: '0.0.0',
},
]
preparePackages(manifests)
await execPnpm(['add', '--recursive', '--save-catalog', '@pnpm.e2e/foo@100.1.0'])
expect(readYamlFile('project-0/pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/foo': {
specifier: '100.1.0',
version: '100.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/foo': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
},
} as Partial<LockfileFile>))
expect(readYamlFile('project-1/pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/foo': {
specifier: '100.1.0',
version: '100.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/foo': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
},
} as Partial<LockfileFile>))
expect(readYamlFile('pnpm-workspace.yaml')).toStrictEqual({
catalog: {
'@pnpm.e2e/foo': '100.1.0',
},
})
expect(loadJsonFile('project-0/package.json')).toStrictEqual({
...manifests[0],
dependencies: {
...manifests[0].dependencies,
'@pnpm.e2e/foo': 'catalog:',
},
} as ProjectManifest)
expect(loadJsonFile('project-1/package.json')).toStrictEqual({
...manifests[1],
dependencies: {
...manifests[1].dependencies,
'@pnpm.e2e/foo': 'catalog:',
},
} as ProjectManifest)
})
test('--save-catalog with a non-default catalog name', async () => {
const manifest: ProjectManifest = {
name: 'test-save-catalog',
version: '0.0.0',
private: true,
dependencies: {
'@pnpm.e2e/bar': 'catalog:',
},
}
prepare(manifest)
writeYamlFile('pnpm-workspace.yaml', {
catalog: {
'@pnpm.e2e/bar': '^100.1.0',
},
})
await addDistTag({ package: '@pnpm.e2e/foo', version: '100.1.0', distTag: 'latest' })
await addDistTag({ package: '@pnpm.e2e/bar', version: '100.1.0', distTag: 'latest' })
await execPnpm(['install'])
expect(readYamlFile('pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/bar': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.1.0',
},
},
},
},
packages: {
'@pnpm.e2e/bar@100.1.0': expect.anything(),
},
} as Partial<LockfileFile>))
await execPnpm(['add', '--save-catalog-name=my-catalog', '@pnpm.e2e/foo'])
expect(readYamlFile('pnpm-lock.yaml')).toStrictEqual(expect.objectContaining({
catalogs: {
default: {
'@pnpm.e2e/bar': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
'my-catalog': {
'@pnpm.e2e/foo': {
specifier: '^100.1.0',
version: '100.1.0',
},
},
},
importers: {
'.': {
dependencies: {
'@pnpm.e2e/bar': {
specifier: 'catalog:',
version: '100.1.0',
},
'@pnpm.e2e/foo': {
specifier: 'catalog:my-catalog',
version: '100.1.0',
},
},
},
},
packages: {
'@pnpm.e2e/bar@100.1.0': expect.anything(),
'@pnpm.e2e/foo@100.1.0': expect.anything(),
},
} as Partial<LockfileFile>))
expect(readYamlFile('pnpm-workspace.yaml')).toStrictEqual({
catalog: {
'@pnpm.e2e/bar': '^100.1.0',
},
catalogs: {
'my-catalog': {
'@pnpm.e2e/foo': '^100.1.0',
},
},
})
expect(loadJsonFile('package.json')).toStrictEqual({
...manifest,
dependencies: {
...manifest.dependencies,
'@pnpm.e2e/foo': 'catalog:my-catalog',
},
})
})

View File

@@ -31,6 +31,7 @@
},
"dependencies": {
"@pnpm/constants": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/object.key-sorting": "workspace:*",
"@pnpm/workspace.read-manifest": "workspace:*",
"ramda": "catalog:",

View File

@@ -1,13 +1,28 @@
import fs from 'fs'
import path from 'path'
import { type ResolvedCatalogEntry } from '@pnpm/lockfile.types'
import { readWorkspaceManifest, type WorkspaceManifest } from '@pnpm/workspace.read-manifest'
import { WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import writeYamlFile from 'write-yaml-file'
import equals from 'ramda/src/equals'
import { sortKeysByPriority } from '@pnpm/object.key-sorting'
async function writeManifestFile (dir: string, manifest: Partial<WorkspaceManifest>): Promise<void> {
manifest = sortKeysByPriority({
priority: { packages: 0 },
deep: false,
}, manifest)
return writeYamlFile(path.join(dir, WORKSPACE_MANIFEST_FILENAME), manifest, {
lineWidth: -1, // This is setting line width to never wrap
blankLines: true,
noCompatMode: true,
noRefs: true,
sortKeys: false,
})
}
export async function updateWorkspaceManifest (dir: string, updatedFields: Partial<WorkspaceManifest>): Promise<void> {
let manifest = await readWorkspaceManifest(dir) ?? {} as WorkspaceManifest
const manifest = await readWorkspaceManifest(dir) ?? {} as WorkspaceManifest
let shouldBeUpdated = false
for (const [key, value] of Object.entries(updatedFields)) {
if (!equals(manifest[key as keyof WorkspaceManifest], value)) {
@@ -27,14 +42,45 @@ export async function updateWorkspaceManifest (dir: string, updatedFields: Parti
await fs.promises.rm(path.join(dir, WORKSPACE_MANIFEST_FILENAME))
return
}
manifest = sortKeysByPriority({
priority: { packages: 0 },
deep: false,
}, manifest)
await writeYamlFile(path.join(dir, WORKSPACE_MANIFEST_FILENAME), manifest, {
lineWidth: -1, // This is setting line width to never wrap
noCompatMode: true,
noRefs: true,
sortKeys: false,
})
await writeManifestFile(dir, manifest)
}
export interface NewCatalogs {
[catalogName: string]: {
[dependencyName: string]: Pick<ResolvedCatalogEntry, 'specifier'>
}
}
export async function addCatalogs (workspaceDir: string, newCatalogs: NewCatalogs): Promise<void> {
const manifest: Partial<WorkspaceManifest> = await readWorkspaceManifest(workspaceDir) ?? {}
let shouldBeUpdated = false
for (const catalogName in newCatalogs) {
let targetCatalog: Record<string, string> | undefined = catalogName === 'default'
? manifest.catalog ?? manifest.catalogs?.default
: manifest.catalogs?.[catalogName]
const targetCatalogWasNil = targetCatalog == null
for (const dependencyName in newCatalogs[catalogName]) {
targetCatalog ??= {}
targetCatalog[dependencyName] = newCatalogs[catalogName][dependencyName].specifier
}
if (targetCatalog == null) continue
shouldBeUpdated = true
if (targetCatalogWasNil) {
if (catalogName === 'default') {
manifest.catalog = targetCatalog
} else {
manifest.catalogs ??= {}
manifest.catalogs[catalogName] = targetCatalog
}
}
}
if (shouldBeUpdated) {
await writeManifestFile(workspaceDir, manifest)
}
}

View File

@@ -0,0 +1,191 @@
import fs from 'fs'
import path from 'path'
import { WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
import { tempDir } from '@pnpm/prepare-temp-dir'
import { addCatalogs } from '@pnpm/workspace.manifest-writer'
import { sync as readYamlFile } from 'read-yaml-file'
import { sync as writeYamlFile } from 'write-yaml-file'
test('addCatalogs does not write new workspace manifest for empty catalogs', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
await addCatalogs(dir, {})
expect(fs.existsSync(filePath)).toBe(false)
})
test('addCatalogs does not write new workspace manifest for empty default catalogs', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
await addCatalogs(dir, {
default: {},
})
expect(fs.existsSync(filePath)).toBe(false)
})
test('addCatalogs does not write new workspace manifest for empty any-named catalogs', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
await addCatalogs(dir, {
foo: {},
bar: {},
})
expect(fs.existsSync(filePath)).toBe(false)
})
test('addCatalogs does not add empty catalogs', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
writeYamlFile(filePath, {})
await addCatalogs(dir, {})
expect(readYamlFile(filePath)).toStrictEqual({})
})
test('addCatalogs does not add empty default catalogs', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
writeYamlFile(filePath, {})
await addCatalogs(dir, {
default: {},
})
expect(readYamlFile(filePath)).toStrictEqual({})
})
test('addCatalogs does not add empty any-named catalogs', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
writeYamlFile(filePath, {})
await addCatalogs(dir, {
foo: {},
bar: {},
})
expect(readYamlFile(filePath)).toStrictEqual({})
})
test('addCatalogs adds `default` catalogs to the `catalog` object by default', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
await addCatalogs(dir, {
default: {
foo: {
specifier: '^0.1.2',
},
},
})
expect(readYamlFile(filePath)).toStrictEqual({
catalog: {
foo: '^0.1.2',
},
})
})
test('addCatalogs adds `default` catalogs to the `catalog` object if it exists', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
writeYamlFile(filePath, {
catalog: {
bar: '3.2.1',
},
})
await addCatalogs(dir, {
default: {
foo: {
specifier: '^0.1.2',
},
},
})
expect(readYamlFile(filePath)).toStrictEqual({
catalog: {
bar: '3.2.1',
foo: '^0.1.2',
},
})
})
test('addCatalogs adds `default` catalogs to the `catalogs.default` object if it exists', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
writeYamlFile(filePath, {
catalogs: {
default: {
bar: '3.2.1',
},
},
})
await addCatalogs(dir, {
default: {
foo: {
specifier: '^0.1.2',
},
},
})
expect(readYamlFile(filePath)).toStrictEqual({
catalogs: {
default: {
bar: '3.2.1',
foo: '^0.1.2',
},
},
})
})
test('addCatalogs creates a `catalogs` object for any-named catalogs', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
await addCatalogs(dir, {
foo: {
abc: {
specifier: '0.1.2',
},
},
bar: {
def: {
specifier: '3.2.1',
},
},
})
expect(readYamlFile(filePath)).toStrictEqual({
catalogs: {
foo: {
abc: '0.1.2',
},
bar: {
def: '3.2.1',
},
},
})
})
test('addCatalogs add any-named catalogs to the `catalogs` object if it already exists', async () => {
const dir = tempDir(false)
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
writeYamlFile(filePath, {
catalogs: {
foo: {
ghi: '7.8.9',
},
},
})
await addCatalogs(dir, {
foo: {
abc: {
specifier: '0.1.2',
},
},
bar: {
def: {
specifier: '3.2.1',
},
},
})
expect(readYamlFile(filePath)).toStrictEqual({
catalogs: {
foo: {
abc: '0.1.2',
ghi: '7.8.9',
},
bar: {
def: '3.2.1',
},
},
})
})

View File

@@ -12,6 +12,9 @@
{
"path": "../../__utils__/prepare-temp-dir"
},
{
"path": "../../lockfile/types"
},
{
"path": "../../object/key-sorting"
},

View File

@@ -1,11 +1,11 @@
import { InvalidWorkspaceManifestError } from './errors/InvalidWorkspaceManifestError'
export interface WorkspaceNamedCatalogs {
readonly [catalogName: string]: WorkspaceCatalog
[catalogName: string]: WorkspaceCatalog
}
export interface WorkspaceCatalog {
readonly [dependencyName: string]: string
[dependencyName: string]: string
}
export function assertValidWorkspaceManifestCatalog (manifest: { packages?: readonly string[], catalog?: unknown }): asserts manifest is { catalog?: WorkspaceCatalog } {