From 9dd97c43a19622628563ab9c1e463a01a4e1b567 Mon Sep 17 00:00:00 2001 From: Austin Fahsl Date: Tue, 2 Apr 2024 13:53:14 -0600 Subject: [PATCH] fix(release): respect root .npmrc registry settings for publishing (cherry picked from commit 12afa20210a699ea17a9e801d89746cdea3a5405) --- docs/generated/manifests/menus.json | 24 + docs/generated/manifests/nx.json | 33 ++ docs/generated/manifests/tags.json | 7 + docs/map.json | 6 + .../nx-release/configure-custom-registries.md | 109 +++++ docs/shared/reference/sitemap.md | 1 + e2e/release/src/custom-registries.test.ts | 430 ++++++++++++++++++ .../release-publish/release-publish.impl.ts | 89 ++-- .../release-version/release-version.ts | 117 +++-- packages/js/src/utils/npm-config.spec.ts | 328 +++++++++++++ packages/js/src/utils/npm-config.ts | 121 +++++ packages/nx/src/utils/package-json.ts | 3 +- 12 files changed, 1164 insertions(+), 104 deletions(-) create mode 100644 docs/shared/recipes/nx-release/configure-custom-registries.md create mode 100644 e2e/release/src/custom-registries.test.ts create mode 100644 packages/js/src/utils/npm-config.spec.ts create mode 100644 packages/js/src/utils/npm-config.ts diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index f858b07ab5452..8dc937dacd87a 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -2300,6 +2300,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Configure Custom Registries", + "path": "/recipes/nx-release/configure-custom-registries", + "id": "configure-custom-registries", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Publish in CI/CD", "path": "/recipes/nx-release/publish-in-ci-cd", @@ -4140,6 +4148,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Configure Custom Registries", + "path": "/recipes/nx-release/configure-custom-registries", + "id": "configure-custom-registries", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Publish in CI/CD", "path": "/recipes/nx-release/publish-in-ci-cd", @@ -4207,6 +4223,14 @@ "children": [], "disableCollapsible": false }, + { + "name": "Configure Custom Registries", + "path": "/recipes/nx-release/configure-custom-registries", + "id": "configure-custom-registries", + "isExternal": false, + "children": [], + "disableCollapsible": false + }, { "name": "Publish in CI/CD", "path": "/recipes/nx-release/publish-in-ci-cd", diff --git a/docs/generated/manifests/nx.json b/docs/generated/manifests/nx.json index d0406a203a6e9..c737d080a7b7f 100644 --- a/docs/generated/manifests/nx.json +++ b/docs/generated/manifests/nx.json @@ -3145,6 +3145,17 @@ "path": "/recipes/nx-release/automatically-version-with-conventional-commits", "tags": ["nx-release"] }, + { + "id": "configure-custom-registries", + "name": "Configure Custom Registries", + "description": "", + "mediaImage": "", + "file": "shared/recipes/nx-release/configure-custom-registries", + "itemList": [], + "isExternal": false, + "path": "/recipes/nx-release/configure-custom-registries", + "tags": ["nx-release"] + }, { "id": "publish-in-ci-cd", "name": "Publish in CI/CD", @@ -5668,6 +5679,17 @@ "path": "/recipes/nx-release/automatically-version-with-conventional-commits", "tags": ["nx-release"] }, + { + "id": "configure-custom-registries", + "name": "Configure Custom Registries", + "description": "", + "mediaImage": "", + "file": "shared/recipes/nx-release/configure-custom-registries", + "itemList": [], + "isExternal": false, + "path": "/recipes/nx-release/configure-custom-registries", + "tags": ["nx-release"] + }, { "id": "publish-in-ci-cd", "name": "Publish in CI/CD", @@ -5761,6 +5783,17 @@ "path": "/recipes/nx-release/automatically-version-with-conventional-commits", "tags": ["nx-release"] }, + "/recipes/nx-release/configure-custom-registries": { + "id": "configure-custom-registries", + "name": "Configure Custom Registries", + "description": "", + "mediaImage": "", + "file": "shared/recipes/nx-release/configure-custom-registries", + "itemList": [], + "isExternal": false, + "path": "/recipes/nx-release/configure-custom-registries", + "tags": ["nx-release"] + }, "/recipes/nx-release/publish-in-ci-cd": { "id": "publish-in-ci-cd", "name": "Publish in CI/CD", diff --git a/docs/generated/manifests/tags.json b/docs/generated/manifests/tags.json index 201bc3dcb822b..7e464bf24f702 100644 --- a/docs/generated/manifests/tags.json +++ b/docs/generated/manifests/tags.json @@ -532,6 +532,13 @@ "name": "Automatically Version with Conventional Commits", "path": "/recipes/nx-release/automatically-version-with-conventional-commits" }, + { + "description": "", + "file": "shared/recipes/nx-release/configure-custom-registries", + "id": "configure-custom-registries", + "name": "Configure Custom Registries", + "path": "/recipes/nx-release/configure-custom-registries" + }, { "description": "", "file": "shared/recipes/nx-release/publish-in-ci-cd", diff --git a/docs/map.json b/docs/map.json index 80923a13087cb..9e166f759615b 100644 --- a/docs/map.json +++ b/docs/map.json @@ -1132,6 +1132,12 @@ "tags": ["nx-release"], "file": "shared/recipes/nx-release/automatically-version-with-conventional-commits" }, + { + "name": "Configure Custom Registries", + "id": "configure-custom-registries", + "tags": ["nx-release"], + "file": "shared/recipes/nx-release/configure-custom-registries" + }, { "name": "Publish in CI/CD", "id": "publish-in-ci-cd", diff --git a/docs/shared/recipes/nx-release/configure-custom-registries.md b/docs/shared/recipes/nx-release/configure-custom-registries.md new file mode 100644 index 0000000000000..027a1068ee057 --- /dev/null +++ b/docs/shared/recipes/nx-release/configure-custom-registries.md @@ -0,0 +1,109 @@ +# Configure Custom Registries + +To publish JavaScript packages, Nx Release uses the `npm` CLI under the hood, which defaults to publishing to the `npm` registry (`https://registry.npmjs.org/`). If you need to publish to a different registry, you can configure the registry in the `.npmrc` file in the root of your workspace or at the project level in the project configuration. + +## Set the Registry in the Root .npmrc File + +The easiest way to configure a custom registry is to set it in the `npm` configuration via the root `.npmrc` file. This file is located in the root of your workspace, and Nx Release will use it for publishing all projects. To set the registry, add the 'registry' property to your root `.npmrc` file: + +```bash .npmrc +registry=https://my-custom-registry.com/ +``` + +### Authenticate to the Registry in CI + +To authenticate with a custom registry in CI, you can add authentication tokens to the `.npmrc` file: + +```bash .npmrc +registry=https://my-custom-registry.com/ +//my-custom-registry.com/:_authToken= +``` + +See the [npm documentation](https://docs.npmjs.com/cli/v10/configuring-npm/npmrc#auth-related-configuration) for more information. + +## Configure Multiple Registries + +The recommended way to determine which registry packages are published to is by using [npm scopes](https://docs.npmjs.com/cli/v10/using-npm/scope). All packages with a name that starts with your scope will be published to the registry specified in the `.npmrc` file for that scope. Consider the following example: + +```bash .npmrc +@my-scope:registry=https://my-custom-registry.com/ +//my-custom-registry.com/:_authToken= + +@other-scope:registry=https://my-other-registry.com/ +//my-other-registry.com/:_authToken= + +registry=https://my-default-registry.com/ +//my-default-registry.com/:_authToken= +``` + +With the above `.npmrc`, the following packages would be published to the specified registries: + +- `@my-scope/pkg-1` -> `https://my-custom-registry.com/` +- `@other-scope/pkg-2` -> `https://my-other-registry.com/` +- `pkg-3` -> `https://my-default-registry.com/` + +## Specify an Alternate Registry for a Single Package + +In some cases, you may want to configure the registry on a per-package basis instead of by scope. This can be done by setting options in the project's configuration. + +{% callout type="info" title="Authentication" %} +All registries set for specific packages must still have authentication tokens set in the root `.npmrc` file for publishing in CI. See [Authenticate to the Registry in CI](#authenticate-to-the-registry-in-ci) for an example. +{% /callout %} + +### Set the Registry in the Project Configuration + +The project configuration for Nx Release is in two parts - one for the version step and one for the publish step. + +#### Update the Version Step + +The version step of Nx Release is responsible for determining the new version of the package. If you have set the `version.generatorOptions.currentVersionResolver` to 'registry', then Nx Release will check the remote registry for the current version of the package. + +**Note:** If you do not use the 'registry' current version resolver, then this step is not needed. + +To set custom registry options for the current version lookup, add the registry and/or tag to the `currentVersionResolverMetadata` in the project configuration: + +```json project.json +{ + "name": "pkg-5", + "sourceRoot": "...", + "targets": { + ... + }, + "release": { + "version": { + "generatorOptions": { + "currentVersionResolverMetadata": { + "registry": "https://my-unique-registry.com/", + "tag": "next" + } + } + } + } +} +``` + +#### Update the Publish Step + +The publish step of Nx Release is responsible for publishing the package to the registry. To set custom registry options for publishing, you can add the `registry` and/or `tag` options for the `nx-release-publish` target in the project configuration: + +```json project.json +{ + "name": "pkg-5", + "sourceRoot": "...", + "targets": { + ..., + "nx-release-publish": { + "options": { + "registry": "https://my-unique-registry.com/", + "tag": "next" + } + } + } +} +``` + +### Set the Registry in the Package Manifest + +{% callout type="caution" title="Caution" %} +It is not recommended to set the registry for a package in the 'publishConfig' property of its 'package.json' file. 'npm publish' will always prefer the registry from the 'publishConfig' over the '--registry' argument. Because of this, the '--registry' CLI and programmatic API options of Nx Release will no longer be able to override the registry for purposes such as publishing locally for end to end testing. +{% /callout %} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index b0486d36d0c81..e6a365668bc2f 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -180,6 +180,7 @@ - [Get Started with Nx Release](/recipes/nx-release/get-started-with-nx-release) - [Release Projects Independently](/recipes/nx-release/release-projects-independently) - [Automatically Version with Conventional Commits](/recipes/nx-release/automatically-version-with-conventional-commits) + - [Configure Custom Registries](/recipes/nx-release/configure-custom-registries) - [Publish in CI/CD](/recipes/nx-release/publish-in-ci-cd) - [Automate GitHub Releases](/recipes/nx-release/automate-github-releases) - [Publish Rust Crates](/recipes/nx-release/publish-rust-crates) diff --git a/e2e/release/src/custom-registries.test.ts b/e2e/release/src/custom-registries.test.ts new file mode 100644 index 0000000000000..2bf88f66bce38 --- /dev/null +++ b/e2e/release/src/custom-registries.test.ts @@ -0,0 +1,430 @@ +import { NxJsonConfiguration, ProjectConfiguration } from '@nx/devkit'; +import { + cleanupProject, + createFile, + killProcessAndPorts, + newProject, + runCLI, + runCommandUntil, + uniq, + updateFile, + updateJson, +} from '@nx/e2e/utils'; +import { execSync } from 'child_process'; +import type { PackageJson } from 'nx/src/utils/package-json'; + +describe('nx release - custom npm registries', () => { + const verdaccioPort = 7191; + const customRegistryUrl = `http://localhost:${verdaccioPort}`; + const scope = 'scope'; + + beforeAll(async () => { + newProject({ + unsetProjectNameAndRootFormat: false, + packages: ['@nx/js'], + }); + }, 60000); + afterAll(() => cleanupProject()); + + it('should respect registry configuration for each package', async () => { + updateJson('nx.json', (nxJson) => { + nxJson.release = { + projectsRelationship: 'independent', + }; + return nxJson; + }); + + const e2eRegistryUrl = execSync('npm config get registry') + .toString() + .trim(); + + const npmrcEntries = [ + `@${scope}:registry=http://scoped-registry.com`, + 'tag=next', + // We can't test overriding the default registry in this file since our e2e tests override it anyway. + // Instead, we'll just assert that the e2e registry is used anytime we expect the default registry + ]; + createFile('.npmrc', npmrcEntries.join('\n')); + + const scopedWithPublishConfig = newPackage('pkg-scoped-publish-config', { + scoped: true, + publishConfig: { + [`@${scope}:registry`]: 'http://publish-config-registry.com', + }, + }); + + const publishResultScopedWithPublishConfig = runCLI( + `release publish -p ${scopedWithPublishConfig} --dry-run` + ); + expect(publishResultScopedWithPublishConfig).toContain( + 'Would publish to http://publish-config-registry.com with tag "next"' + ); + + const scopedWithPublishConfigAndProjectConfig = newPackage( + 'pkg-scoped-publish-config-project-config', + { + scoped: true, + publishConfig: { + [`@${scope}:registry`]: 'http://publish-config-registry.com', + }, + projectConfig: { + registry: 'http://ignored-registry.com', + tag: 'alpha', + }, + } + ); + + const publishResultScopedWithPublishConfigAndProjectConfig = runCLI( + `release publish -p ${scopedWithPublishConfigAndProjectConfig} --dry-run` + ); + expect(publishResultScopedWithPublishConfigAndProjectConfig).toContain( + 'Would publish to http://publish-config-registry.com with tag "alpha"' + ); + + const publishResultScopedWithPublishConfigAndProjectConfigAndArg = runCLI( + `release publish -p ${scopedWithPublishConfigAndProjectConfig} --dry-run --registry=http://ignored-registry.com` + ); + expect( + publishResultScopedWithPublishConfigAndProjectConfigAndArg + ).toContain( + 'Would publish to http://publish-config-registry.com with tag "alpha"' + ); + + const scopedNoOtherConfig = newPackage('pkg-scoped-no-other-config', { + scoped: true, + }); + + const publishResultScopedNoOtherConfig = runCLI( + `release publish -p ${scopedNoOtherConfig} --dry-run` + ); + expect(publishResultScopedNoOtherConfig).toContain( + 'Would publish to http://scoped-registry.com with tag "next"' + ); + + const publishResultScopedWithRegistryArg = runCLI( + `release publish -p ${scopedNoOtherConfig} --dry-run --registry=http://scope-override-registry.com` + ); + expect(publishResultScopedWithRegistryArg).toContain( + 'Would publish to http://scope-override-registry.com with tag "next"' + ); + + const noScopeWithPublishConfigAndProjectConfig = newPackage( + 'pkg-no-scope-publish-config-project-config', + { + publishConfig: { + registry: 'http://publish-config-registry.com', + }, + projectConfig: { + registry: 'http://ignored-registry.com', + tag: 'alpha', + }, + } + ); + + const publishResultNoScopeWithPublishConfigAndProjectConfig = runCLI( + `release publish -p ${noScopeWithPublishConfigAndProjectConfig} --dry-run` + ); + expect(publishResultNoScopeWithPublishConfigAndProjectConfig).toContain( + 'Would publish to http://publish-config-registry.com with tag "alpha"' + ); + + const publishResultNoScopeWithPublishConfigAndProjectConfigAndArg = runCLI( + `release publish -p ${noScopeWithPublishConfigAndProjectConfig} --dry-run --registry=http://ignored-registry.com --tag=beta` + ); + expect( + publishResultNoScopeWithPublishConfigAndProjectConfigAndArg + ).toContain( + `Would publish to http://publish-config-registry.com with tag "beta"` + ); + + const noScopeNoOtherConfig = newPackage('pkg-no-scope-no-config', {}); + + const publishResultNoScope = runCLI( + `release publish -p ${noScopeNoOtherConfig} --dry-run` + ); + expect(publishResultNoScope).toContain( + `Would publish to ${e2eRegistryUrl} with tag "next"` + ); + + const publishResultNoScopeWithRegistryArg = runCLI( + `release publish -p ${noScopeNoOtherConfig} --dry-run --registry=${customRegistryUrl} --tag=alpha` + ); + expect(publishResultNoScopeWithRegistryArg).toContain( + `Would publish to ${customRegistryUrl} with tag "alpha"` + ); + + const scopeWithProjectConfig = newPackage('pkg-scope-project-config', { + scoped: true, + projectConfig: { + registry: 'http://scope-override-registry.com', + tag: 'alpha', + }, + }); + + const publishResultScopedWithProjectConfig = runCLI( + `release publish -p ${scopeWithProjectConfig} --dry-run` + ); + expect(publishResultScopedWithProjectConfig).toContain( + 'Would publish to http://scope-override-registry.com with tag "alpha"' + ); + const publishResultScopedWithProjectConfigAndArg = runCLI( + `release publish -p ${scopeWithProjectConfig} --dry-run --registry=http://scope-override-arg-registry.com --tag=prev` + ); + expect(publishResultScopedWithProjectConfigAndArg).toContain( + 'Would publish to http://scope-override-arg-registry.com with tag "prev"' + ); + + const noScopeWithProjectConfig = newPackage('pkg-no-scope-project-config', { + projectConfig: { + registry: 'http://default-override-registry.com', + tag: 'alpha', + }, + }); + + const publishResultNoScopeWithProjectConfig = runCLI( + `release publish -p ${noScopeWithProjectConfig} --dry-run` + ); + expect(publishResultNoScopeWithProjectConfig).toContain( + 'Would publish to http://default-override-registry.com with tag "alpha"' + ); + const publishResultNoScopeWithProjectConfigAndArg = runCLI( + `release publish -p ${noScopeWithProjectConfig} --dry-run --registry=http://default-override-arg-registry.com --tag=prev` + ); + expect(publishResultNoScopeWithProjectConfigAndArg).toContain( + 'Would publish to http://default-override-arg-registry.com with tag "prev"' + ); + + runCLI(`generate setup-verdaccio`); + + const process = await runCommandUntil( + `local-registry @proj/source --port=${verdaccioPort}`, + (output) => output.includes(`warn --- http address`) + ); + + const npmrcEntries2 = [ + `@${scope}:registry=${customRegistryUrl}`, + `registry=http://ignored-registry.com`, + 'tag=next', + ]; + updateFile('.npmrc', npmrcEntries2.join('\n')); + + const actualScopedWithPublishConfigAndProjectConfig = newPackage( + 'pkg-actual-scoped-publish-config-project-config', + { + scoped: true, + publishConfig: { + [`@${scope}:registry`]: e2eRegistryUrl, + }, + projectConfig: { + registry: 'http://ignored-registry.com', + tag: 'beta', + version: { + tag: 'alpha', // alpha tag will be passed via publish CLI arg to override the above 'beta' + }, + }, + } + ); + + const actualPublishResultScoped = runCLI( + `release publish -p ${actualScopedWithPublishConfigAndProjectConfig} --registry=http://ignored-registry.com --tag=alpha` + ); + + const actualScopedWithWrongPublishConfig = newPackage( + 'pkg-actual-scoped-wrong-publish-config', + { + scoped: true, + publishConfig: { + // to properly override the registry for a scoped package, the key needs to include the scope + registry: 'http://ignored-registry.com', + }, + projectConfig: {}, // this will still set the current version resolver to 'registry', it just won't set the registry url + } + ); + + const actualPublishResultScopedWithWrongPublishConfig = runCLI( + `release publish -p ${actualScopedWithWrongPublishConfig}` + ); + + const actualNoScopeWithProjectConfig = newPackage( + 'pkg-actual-no-scope-project-config', + { + projectConfig: { + registry: customRegistryUrl, + tag: 'beta', + }, + } + ); + + const actualPublishResultNoScopeWithProjectConfig = runCLI( + `release publish -p ${actualNoScopeWithProjectConfig}` + ); + + const actualScopedWithProjectConfig = newPackage( + 'pkg-actual-scoped-project-config', + { + scoped: true, + projectConfig: { + registry: e2eRegistryUrl, + tag: 'prev', + }, + } + ); + + const actualPublishResultScopedWithProjectConfig = runCLI( + `release publish -p ${actualScopedWithProjectConfig}` + ); + + const versionResult = runCLI( + `release version 999.9.9 -p "${actualScopedWithPublishConfigAndProjectConfig},${actualScopedWithWrongPublishConfig},${actualNoScopeWithProjectConfig},${actualScopedWithProjectConfig}" --dry-run`, + { silenceError: true } // don't error on this command because the verdaccio process needs to be killed regardless before the test exits + ); + + await killProcessAndPorts(process.pid, verdaccioPort); + + expect(actualPublishResultScoped).toContain( + `Published to ${e2eRegistryUrl} with tag "alpha"` + ); + + expect(actualPublishResultScopedWithWrongPublishConfig).toContain( + `Published to ${customRegistryUrl} with tag "next"` + ); + + expect(actualPublishResultNoScopeWithProjectConfig).toContain( + `Published to ${customRegistryUrl} with tag "beta"` + ); + + expect(actualPublishResultScopedWithProjectConfig).toContain( + `Published to ${e2eRegistryUrl} with tag "prev"` + ); + + expect( + versionResult.match( + new RegExp( + `Resolved the current version as 0.0.0 for tag "alpha" from registry ${e2eRegistryUrl}`, + 'g' + ) + ).length + ).toBe(1); + + expect( + versionResult.match( + new RegExp( + `Resolved the current version as 0.0.0 for tag "next" from registry ${customRegistryUrl}`, + 'g' + ) + ).length + ).toBe(1); + + expect( + versionResult.match( + new RegExp( + `Resolved the current version as 0.0.0 for tag "beta" from registry ${customRegistryUrl}`, + 'g' + ) + ).length + ).toBe(1); + + expect( + versionResult.match( + new RegExp( + `Resolved the current version as 0.0.0 for tag "prev" from registry ${e2eRegistryUrl}`, + 'g' + ) + ).length + ).toBe(1); + }, 600000); + + function newPackage( + name: string, + options: { + scoped?: true; + publishConfig?: Record; + projectConfig?: { + registry?: string; + tag?: string; + version?: { + registry?: string; + tag?: string; + }; + publish?: { + registry?: string; + tag?: string; + }; + }; + } + ): string { + const projectName = uniq(name); + runCLI(`generate @nx/workspace:npm-package ${projectName}`); + + let packageName = projectName; + if (options.scoped) { + packageName = `@${scope}/${projectName}`; + } + + updateJson(`${projectName}/package.json`, (json) => { + json.name = packageName; + if (options.publishConfig) { + json.publishConfig = options.publishConfig; + } + return json; + }); + + updateJson(`${projectName}/project.json`, (json) => { + if (options.projectConfig) { + json.release = { + version: { + generatorOptions: { + currentVersionResolver: 'registry', + currentVersionResolverMetadata: {}, + }, + }, + }; + json.targets = { + ...json.targets, + 'nx-release-publish': { + options: {}, + }, + }; + } + if (options.projectConfig?.registry) { + json.targets['nx-release-publish'].options.registry = + options.projectConfig.registry; + ( + json.release.version.generatorOptions + .currentVersionResolverMetadata as Record + ).registry = options.projectConfig.registry; + } + if (options.projectConfig?.tag) { + json.targets['nx-release-publish'].options.tag = + options.projectConfig.tag; + ( + json.release.version.generatorOptions + .currentVersionResolverMetadata as Record + ).tag = options.projectConfig.tag; + } + if (options.projectConfig?.publish?.registry) { + json.targets['nx-release-publish'].options.registry = + options.projectConfig.publish.registry; + } + if (options.projectConfig?.publish?.tag) { + json.targets['nx-release-publish'].options.tag = + options.projectConfig.publish.tag; + } + if (options.projectConfig?.version?.registry) { + ( + json.release.version.generatorOptions + .currentVersionResolverMetadata as Record + ).registry = options.projectConfig.version.registry; + } + if (options.projectConfig?.version?.tag) { + ( + json.release.version.generatorOptions + .currentVersionResolverMetadata as Record + ).tag = options.projectConfig.version.tag; + } + return json; + }); + + return projectName; + } +}); diff --git a/packages/js/src/executors/release-publish/release-publish.impl.ts b/packages/js/src/executors/release-publish/release-publish.impl.ts index c1da27ec7c845..03b75dff9a096 100644 --- a/packages/js/src/executors/release-publish/release-publish.impl.ts +++ b/packages/js/src/executors/release-publish/release-publish.impl.ts @@ -1,6 +1,8 @@ -import { ExecutorContext, joinPathFragments, readJsonFile } from '@nx/devkit'; +import { ExecutorContext, readJsonFile } from '@nx/devkit'; import { execSync } from 'child_process'; import { env as appendLocalEnv } from 'npm-run-path'; +import { join } from 'path'; +import { parseRegistryOptions } from '../../utils/npm-config'; import { logTar } from './log-tar'; import { PublishExecutorSchema } from './schema'; import chalk = require('chalk'); @@ -32,14 +34,14 @@ export default async function runExecutor( const projectConfig = context.projectsConfigurations!.projects[context.projectName!]!; - const packageRoot = joinPathFragments( + const packageRoot = join( context.root, options.packageRoot ?? projectConfig.root ); - const packageJsonPath = joinPathFragments(packageRoot, 'package.json'); - const projectPackageJson = readJsonFile(packageJsonPath); - const packageName = projectPackageJson.name; + const packageJsonPath = join(packageRoot, 'package.json'); + const packageJson = readJsonFile(packageJsonPath); + const packageName = packageJson.name; // If package and project name match, we can make log messages terser let packageTxt = @@ -47,7 +49,7 @@ export default async function runExecutor( ? `package "${packageName}"` : `package "${packageName}" from project "${context.projectName}"`; - if (projectPackageJson.private === true) { + if (packageJson.private === true) { console.warn( `Skipped ${packageTxt}, because it has \`"private": true\` in ${packageJsonPath}` ); @@ -56,32 +58,28 @@ export default async function runExecutor( }; } - const npmPublishCommandSegments = [`npm publish --json`]; + const warnFn = (message: string) => { + console.log(chalk.keyword('orange')(message)); + }; + const { registry, tag, registryConfigKey } = await parseRegistryOptions( + context.root, + { + packageRoot, + packageJson, + }, + { + registry: options.registry, + tag: options.tag, + }, + warnFn + ); + const npmViewCommandSegments = [ - `npm view ${packageName} versions dist-tags --json`, + `npm view ${packageName} versions dist-tags --json --"${registryConfigKey}=${registry}"`, + ]; + const npmDistTagAddCommandSegments = [ + `npm dist-tag add ${packageName}@${packageJson.version} ${tag} --"${registryConfigKey}=${registry}"`, ]; - - if (options.registry) { - npmPublishCommandSegments.push(`--registry=${options.registry}`); - npmViewCommandSegments.push(`--registry=${options.registry}`); - } - - if (options.tag) { - npmPublishCommandSegments.push(`--tag=${options.tag}`); - } - - if (options.otp) { - npmPublishCommandSegments.push(`--otp=${options.otp}`); - } - - if (isDryRun) { - npmPublishCommandSegments.push(`--dry-run`); - } - - // Resolve values using the `npm config` command so that things like environment variables and `publishConfig`s are accounted for - const registry = - options.registry ?? execSync(`npm config get registry`).toString().trim(); - const tag = options.tag ?? execSync(`npm config get tag`).toString().trim(); /** * In a dry-run scenario, it is most likely that all commands are being run with dry-run, therefore @@ -92,11 +90,11 @@ export default async function runExecutor( * perform the npm view step, and just show npm publish's dry-run output. */ if (!isDryRun && !options.firstRelease) { - const currentVersion = projectPackageJson.version; + const currentVersion = packageJson.version; try { const result = execSync(npmViewCommandSegments.join(' '), { env: processEnv(true), - cwd: packageRoot, + cwd: context.root, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -114,14 +112,11 @@ export default async function runExecutor( if (resultJson.versions.includes(currentVersion)) { try { if (!isDryRun) { - execSync( - `npm dist-tag add ${packageName}@${currentVersion} ${tag} --registry=${registry}`, - { - env: processEnv(true), - cwd: packageRoot, - stdio: 'ignore', - } - ); + execSync(npmDistTagAddCommandSegments.join(' '), { + env: processEnv(true), + cwd: context.root, + stdio: 'ignore', + }); console.log( `Added the dist-tag ${tag} to v${currentVersion} for registry ${registry}.\n` ); @@ -205,11 +200,23 @@ export default async function runExecutor( console.log('Skipped npm view because --first-release was set'); } + const npmPublishCommandSegments = [ + `npm publish ${packageRoot} --json --"${registryConfigKey}=${registry}" --tag=${tag}`, + ]; + + if (options.otp) { + npmPublishCommandSegments.push(`--otp=${options.otp}`); + } + + if (isDryRun) { + npmPublishCommandSegments.push(`--dry-run`); + } + try { const output = execSync(npmPublishCommandSegments.join(' '), { maxBuffer: LARGE_BUFFER, env: processEnv(true), - cwd: packageRoot, + cwd: context.root, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/packages/js/src/generators/release-version/release-version.ts b/packages/js/src/generators/release-version/release-version.ts index 04f5852c91454..a910e84d2b92b 100644 --- a/packages/js/src/generators/release-version/release-version.ts +++ b/packages/js/src/generators/release-version/release-version.ts @@ -2,7 +2,6 @@ import { ProjectGraphProjectNode, Tree, formatFiles, - joinPathFragments, output, readJson, updateJson, @@ -11,7 +10,7 @@ import { } from '@nx/devkit'; import * as chalk from 'chalk'; import { exec } from 'node:child_process'; -import { relative } from 'node:path'; +import { join } from 'node:path'; import { IMPLICIT_DEFAULT_RELEASE_GROUP } from 'nx/src/command-line/release/config/config'; import { getFirstGitCommit, @@ -31,6 +30,7 @@ import { import { interpolate } from 'nx/src/tasks-runner/utils'; import * as ora from 'ora'; import { prerelease } from 'semver'; +import { parseRegistryOptions } from '../../utils/npm-config'; import { ReleaseVersionGeneratorSchema } from './schema'; import { resolveLocalPackageDependencies } from './utils/resolve-local-package-dependencies'; import { updateLockFile } from './utils/update-lock-file'; @@ -111,11 +111,7 @@ Valid values are: ${validReleaseVersionPrefixes ); } - const packageJsonPath = joinPathFragments(packageRoot, 'package.json'); - const workspaceRelativePackageJsonPath = relative( - workspaceRoot, - packageJsonPath - ); + const packageJsonPath = join(packageRoot, 'package.json'); const color = getColor(projectName); const log = (msg: string) => { @@ -124,7 +120,7 @@ Valid values are: ${validReleaseVersionPrefixes if (!tree.exists(packageJsonPath)) { throw new Error( - `The project "${projectName}" does not have a package.json available at ${workspaceRelativePackageJsonPath}. + `The project "${projectName}" does not have a package.json available at ${packageJsonPath}. To fix this you will either need to add a package.json file at that location, or configure "release" within your nx.json to exclude "${projectName}" from the current release group, or amend the packageRoot configuration to point to where the package.json should be.` ); @@ -136,22 +132,40 @@ To fix this you will either need to add a package.json file at that location, or )}` ); - const projectPackageJson = readJson(tree, packageJsonPath); + const packageJson = readJson(tree, packageJsonPath); log( - `🔍 Reading data for package "${projectPackageJson.name}" from ${workspaceRelativePackageJsonPath}` + `🔍 Reading data for package "${packageJson.name}" from ${packageJsonPath}` ); const { name: packageName, version: currentVersionFromDisk } = - projectPackageJson; + packageJson; switch (options.currentVersionResolver) { case 'registry': { const metadata = options.currentVersionResolverMetadata; - const registry = - metadata?.registry ?? - (await getNpmRegistry()) ?? - 'https://registry.npmjs.org'; - const tag = metadata?.tag ?? 'latest'; + const registryArg = + typeof metadata?.registry === 'string' + ? metadata.registry + : undefined; + const tagArg = + typeof metadata?.tag === 'string' ? metadata.tag : undefined; + + const warnFn = (message: string) => { + console.log(chalk.keyword('orange')(message)); + }; + const { registry, tag, registryConfigKey } = + await parseRegistryOptions( + workspaceRoot, + { + packageRoot: join(workspaceRoot, packageRoot), + packageJson, + }, + { + registry: registryArg, + tag: tagArg, + }, + warnFn + ); /** * If the currentVersionResolver is set to registry, and the projects are not independent, we only want to make the request once for the whole batch of projects. @@ -174,7 +188,7 @@ To fix this you will either need to add a package.json file at that location, or // Must be non-blocking async to allow spinner to render currentVersion = await new Promise((resolve, reject) => { exec( - `npm view ${packageName} version --registry=${registry} --tag=${tag}`, + `npm view ${packageName} version --"${registryConfigKey}=${registry}" --tag=${tag}`, (error, stdout, stderr) => { if (error) { return reject(error); @@ -429,7 +443,7 @@ To fix this you will either need to add a package.json file at that location, or if (!specifier) { log( - `🚫 Skipping versioning "${projectPackageJson.name}" as no changes were detected.` + `🚫 Skipping versioning "${packageJson.name}" as no changes were detected.` ); continue; } @@ -442,13 +456,11 @@ To fix this you will either need to add a package.json file at that location, or versionData[projectName].newVersion = newVersion; writeJson(tree, packageJsonPath, { - ...projectPackageJson, + ...packageJson, version: newVersion, }); - log( - `✍️ New version ${newVersion} written to ${workspaceRelativePackageJsonPath}` - ); + log(`✍️ New version ${newVersion} written to ${packageJsonPath}`); if (dependentProjects.length > 0) { log( @@ -471,34 +483,30 @@ To fix this you will either need to add a package.json file at that location, or `The dependent project "${dependentProject.source}" does not have a packageRoot available. Please report this issue on https://github.com/nrwl/nx` ); } - updateJson( - tree, - joinPathFragments(dependentPackageRoot, 'package.json'), - (json) => { - // Auto (i.e.infer existing) by default - let versionPrefix = options.versionPrefix ?? 'auto'; - - // For auto, we infer the prefix based on the current version of the dependent - if (versionPrefix === 'auto') { - versionPrefix = ''; // we don't want to end up printing auto - - const current = - json[dependentProject.dependencyCollection][packageName]; - if (current) { - const prefixMatch = current.match(/^[~^]/); - if (prefixMatch) { - versionPrefix = prefixMatch[0]; - } else { - versionPrefix = ''; - } + updateJson(tree, join(dependentPackageRoot, 'package.json'), (json) => { + // Auto (i.e.infer existing) by default + let versionPrefix = options.versionPrefix ?? 'auto'; + + // For auto, we infer the prefix based on the current version of the dependent + if (versionPrefix === 'auto') { + versionPrefix = ''; // we don't want to end up printing auto + + const current = + json[dependentProject.dependencyCollection][packageName]; + if (current) { + const prefixMatch = current.match(/^[~^]/); + if (prefixMatch) { + versionPrefix = prefixMatch[0]; + } else { + versionPrefix = ''; } } - json[dependentProject.dependencyCollection][ - packageName - ] = `${versionPrefix}${newVersion}`; - return json; } - ); + json[dependentProject.dependencyCollection][ + packageName + ] = `${versionPrefix}${newVersion}`; + return json; + }); } } @@ -571,18 +579,3 @@ function getColor(projectName: string) { return colors[colorIndex]; } - -async function getNpmRegistry() { - // Must be non-blocking async to allow spinner to render - return await new Promise((resolve, reject) => { - exec('npm config get registry', (error, stdout, stderr) => { - if (error) { - return reject(error); - } - if (stderr) { - return reject(stderr); - } - return resolve(stdout.trim()); - }); - }); -} diff --git a/packages/js/src/utils/npm-config.spec.ts b/packages/js/src/utils/npm-config.spec.ts new file mode 100644 index 0000000000000..720663354eba8 --- /dev/null +++ b/packages/js/src/utils/npm-config.spec.ts @@ -0,0 +1,328 @@ +import { ExecException } from 'child_process'; +import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import { PackageJson } from 'nx/src/utils/package-json'; +import { join } from 'path'; +import { getNpmRegistry, getNpmTag, parseRegistryOptions } from './npm-config'; + +jest.mock('child_process', () => { + const original = jest.requireActual('child_process'); + return { + ...original, + exec: jest + .fn() + .mockImplementation( + ( + command: string, + _: unknown, + callback: ( + error: ExecException, + stdout: string, + stderr: string + ) => void + ) => { + switch (command) { + case 'npm config get @scope:registry': + callback(null, 'https://scoped-registry.com', null); + break; + case 'npm config get @missing:registry': + callback(null, 'undefined', null); + break; + case 'npm config get registry': + callback(null, 'https://custom-registry.com', null); + break; + case 'npm config get tag': + callback(null, 'next', null); + break; + default: + callback( + new Error(`unexpected command: ${command}`), + null, + 'ERROR' + ); + } + } + ), + }; +}); + +describe('npm-config', () => { + let tempFs: TempFs; + + beforeEach(() => { + tempFs = new TempFs('npm-config'); + }); + + describe('getNpmRegistry', () => { + it('should return scoped registry if it exists', async () => { + const registry = await getNpmRegistry(tempFs.tempDir, '@scope'); + expect(registry).toEqual('https://scoped-registry.com'); + }); + + it('should return registry if scoped registry does not exist', async () => { + const registry = await getNpmRegistry(tempFs.tempDir, '@missing'); + expect(registry).toEqual('https://custom-registry.com'); + }); + + it('should return registry if package is not scoped', async () => { + const registry = await getNpmRegistry(tempFs.tempDir); + expect(registry).toEqual('https://custom-registry.com'); + }); + }); + + describe('getNpmTag', () => { + it('should return tag from npm config', async () => { + const tag = await getNpmTag(tempFs.tempDir); + expect(tag).toEqual('next'); + }); + }); + + describe('parseRegistryOptions', () => { + let logMessage: string; + const logFn = (message: string) => { + logMessage += message; + }; + + beforeEach(() => { + logMessage = ''; + }); + + it('should warn if .npmrc exists in the package root', async () => { + await tempFs.createFile( + join('packages', 'pkg1', '.npmrc'), + 'registry=https://custom-registry.com' + ); + await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: join(tempFs.tempDir, 'packages', 'pkg1'), + packageJson: { + name: 'pkg1', + } as PackageJson, + }, + {}, + logFn + ); + + expect(logMessage).toContain( + 'Ignoring .npmrc file detected in the package root' + ); + }); + + it('should warn and return registry set in publishConfig', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: 'pkg1', + publishConfig: { + registry: 'https://publish-config.com', + } as PackageJson['publishConfig'], + } as PackageJson, + }, + {}, + logFn + ); + + expect(logMessage).toContain("Registry detected in the 'publishConfig'"); + expect(logMessage).toContain( + 'prevents the registry from being overridden' + ); + expect(registry).toEqual('https://publish-config.com'); + expect(registryConfigKey).toEqual('registry'); + }); + + it('should warn and return registry set in publishConfig instead of registry arg', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: 'pkg1', + publishConfig: { + registry: 'https://publish-config.com', + } as PackageJson['publishConfig'], + } as PackageJson, + }, + { + registry: 'https://ignored-registry.com', + }, + logFn + ); + + expect(logMessage).toContain("Registry detected in the 'publishConfig'"); + expect(logMessage).toContain('This will override your registry option'); + expect(registry).toEqual('https://publish-config.com'); + expect(registryConfigKey).toEqual('registry'); + }); + + it('should warn and return scoped registry set in publishConfig instead of registry arg for a scoped package', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: '@scope/pkg1', + publishConfig: { + '@scope:registry': 'https://publish-config.com', + } as PackageJson['publishConfig'], + } as PackageJson, + }, + { + registry: 'https://ignored-registry.com', + }, + logFn + ); + + expect(logMessage).toContain("Registry detected in the 'publishConfig'"); + expect(registry).toContain('https://publish-config.com'); + expect(registryConfigKey).toEqual('@scope:registry'); + }); + + it('should warn if registry is set in publishConfig for a scoped package, but still return registry arg', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: '@scope/pkg1', + publishConfig: { + registry: 'https://publish-config.com', + } as PackageJson['publishConfig'], + } as PackageJson, + }, + { + registry: 'https://registry-arg.com', + }, + logFn + ); + + expect(logMessage).toContain("Registry detected in the 'publishConfig'"); + expect(registry).toContain('https://registry-arg.com'); + expect(registryConfigKey).toEqual('@scope:registry'); + }); + + it('should return registry arg over npm config', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: 'pkg1', + } as PackageJson, + }, + { + registry: 'https://registry-arg.com', + }, + logFn + ); + + expect(registry).toEqual('https://registry-arg.com'); + expect(registryConfigKey).toEqual('registry'); + }); + + it('should return registry arg over npm config for scoped packages', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: '@scope/pkg1', + } as PackageJson, + }, + { + registry: 'https://registry-arg.com', + }, + logFn + ); + + expect(registry).toEqual('https://registry-arg.com'); + expect(registryConfigKey).toEqual('@scope:registry'); + }); + + it('should defer to npm config for scoped registry', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: '@scope/pkg1', + } as PackageJson, + }, + {}, + logFn + ); + + expect(registry).toEqual('https://scoped-registry.com'); + expect(registryConfigKey).toEqual('@scope:registry'); + }); + + it('should defer to npm config for registry if scoped registry does not exist', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: '@missing/pkg1', + } as PackageJson, + }, + {}, + logFn + ); + + expect(registry).toEqual('https://custom-registry.com'); + expect(registryConfigKey).toEqual('@missing:registry'); + }); + + it('should defer to npm config for registry if package is not scoped', async () => { + const { registry, registryConfigKey } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: 'pkg1', + } as PackageJson, + }, + {}, + logFn + ); + + expect(registry).toEqual('https://custom-registry.com'); + expect(registryConfigKey).toEqual('registry'); + }); + + it('should return npm tag from config', async () => { + const { tag } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: 'pkg1', + } as PackageJson, + }, + {}, + logFn + ); + + expect(tag).toEqual('next'); + }); + + it('should override npm tag when tag option is passed', async () => { + const { tag } = await parseRegistryOptions( + tempFs.tempDir, + { + packageRoot: tempFs.tempDir, + packageJson: { + name: 'pkg1', + } as PackageJson, + }, + { + tag: 'alpha', + }, + logFn + ); + + expect(tag).toEqual('alpha'); + }); + }); +}); diff --git a/packages/js/src/utils/npm-config.ts b/packages/js/src/utils/npm-config.ts new file mode 100644 index 0000000000000..61c6113c434b2 --- /dev/null +++ b/packages/js/src/utils/npm-config.ts @@ -0,0 +1,121 @@ +import { exec } from 'child_process'; +import { existsSync } from 'fs'; +import { PackageJson } from 'nx/src/utils/package-json'; +import { join, relative } from 'path'; + +export async function parseRegistryOptions( + cwd: string, + pkg: { + packageRoot: string; + packageJson: PackageJson; + }, + options: { + registry?: string; + tag?: string; + }, + logWarnFn: (message: string) => void = console.warn +): Promise<{ registry: string; tag: string; registryConfigKey: string }> { + const npmRcPath = join(pkg.packageRoot, '.npmrc'); + if (existsSync(npmRcPath)) { + const relativeNpmRcPath = relative(cwd, npmRcPath); + logWarnFn( + `\nIgnoring .npmrc file detected in the package root: ${relativeNpmRcPath}. Nested .npmrc files are not supported by npm. Only the .npmrc file at the root of the workspace will be used. To customize the registry or tag for specific packages, see https://nx.dev/recipes/nx-release/configure-custom-registries\n` + ); + } + + const scope = pkg.packageJson.name.startsWith('@') + ? pkg.packageJson.name.split('/')[0] + : ''; + + // If the package is scoped, then the registry argument that will + // correctly override the registry in the .npmrc file must be scoped. + const registryConfigKey = scope ? `${scope}:registry` : 'registry'; + + const publishConfigRegistry = + pkg.packageJson.publishConfig?.[registryConfigKey]; + + // Even though it won't override the actual registry that's actually used, + // the user might think otherwise, so we should still warn if the user has + // set a 'registry' in 'publishConfig' for a scoped package. + if (publishConfigRegistry || pkg.packageJson.publishConfig?.registry) { + const relativePackageJsonPath = relative( + cwd, + join(pkg.packageRoot, 'package.json') + ); + if (options.registry) { + logWarnFn( + `\nRegistry detected in the 'publishConfig' of the package manifest: ${relativePackageJsonPath}. This will override your registry option set in the project configuration or passed via the --registry argument, which is why configuring the registry with 'publishConfig' is not recommended. For details, see https://nx.dev/recipes/nx-release/configure-custom-registries\n` + ); + } else { + logWarnFn( + `\nRegistry detected in the 'publishConfig' of the package manifest: ${relativePackageJsonPath}. Configuring the registry in this way is not recommended because it prevents the registry from being overridden in project configuration or via the --registry argument. To customize the registry for specific packages, see https://nx.dev/recipes/nx-release/configure-custom-registries\n` + ); + } + } + + const registry = + // `npm publish` will always use the publishConfig registry if it exists, even over the --registry arg + publishConfigRegistry || + options.registry || + (await getNpmRegistry(cwd, scope)); + const tag = options.tag || (await getNpmTag(cwd)); + + return { registry, tag, registryConfigKey }; +} + +/** + * Returns the npm registry that is used for publishing. + * + * @param scope the scope of the package for which to determine the registry + * @param cwd the directory where the npm config should be read from + */ +export async function getNpmRegistry( + cwd: string, + scope?: string +): Promise { + let registry: string | undefined; + + if (scope) { + registry = await getNpmConfigValue(`${scope}:registry`, cwd); + } + + if (!registry) { + registry = await getNpmConfigValue('registry', cwd); + } + + return registry; +} + +/** + * Returns the npm tag that is used for publishing. + * + * @param cwd the directory where the npm config should be read from + */ +export async function getNpmTag(cwd: string): Promise { + // npm does not support '@scope:tag' in the npm config, so we only need to check for 'tag'. + return getNpmConfigValue('tag', cwd); +} + +async function getNpmConfigValue(key: string, cwd: string): Promise { + try { + const result = await execAsync(`npm config get ${key}`, cwd); + return result === 'undefined' ? undefined : result; + } catch (e) { + return Promise.resolve(undefined); + } +} + +async function execAsync(command: string, cwd: string): Promise { + // Must be non-blocking async to allow spinner to render + return new Promise((resolve, reject) => { + exec(command, { cwd }, (error, stdout, stderr) => { + if (error) { + return reject(error); + } + if (stderr) { + return reject(stderr); + } + return resolve(stdout.trim()); + }); + }); +} diff --git a/packages/nx/src/utils/package-json.ts b/packages/nx/src/utils/package-json.ts index 4c8e288f81872..0b7de85bf4b99 100644 --- a/packages/nx/src/utils/package-json.ts +++ b/packages/nx/src/utils/package-json.ts @@ -4,9 +4,9 @@ import { InputDefinition, TargetConfiguration, } from '../config/workspace-json-project-json'; +import { mergeTargetConfigurations } from '../project-graph/utils/project-configuration-utils'; import { readJsonFile } from './fileutils'; import { getNxRequirePaths } from './installation-directory'; -import { mergeTargetConfigurations } from '../project-graph/utils/project-configuration-utils'; export interface NxProjectPackageJsonConfiguration { implicitDependencies?: string[]; @@ -59,6 +59,7 @@ export interface PackageJson { | { packages: string[]; }; + publishConfig?: Record; // Nx Project Configuration nx?: NxProjectPackageJsonConfiguration;