From 03e81571b8e68bc54fa69afbbc00f6338b39b19f Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Sat, 19 Nov 2022 17:40:50 -0500 Subject: [PATCH] feat(publish): apply publishConfig overrides, closes #404 (#415) * feat(publish): apply publishConfig overrides, closes #404 Co-authored-by: changyoung --- __fixtures__/normal-publish-config/lerna.json | 8 + .../normal-publish-config/package.json | 3 + .../packages/package-1/package.json | 11 ++ .../packages/package-2/package.json | 25 +++ .../packages/package-3/package.json | 10 + packages/cli/schemas/lerna-schema.json | 7 + .../src/cli-commands/cli-publish-commands.ts | 9 + packages/cli/src/lerna-entry.ts | 9 +- packages/core/src/models/command-options.ts | 3 + packages/core/src/models/interfaces.ts | 5 + packages/core/src/package.ts | 5 + .../src/utils/__tests__/object-utils.spec.ts | 24 ++- packages/core/src/utils/object-utils.ts | 8 + packages/publish/README.md | 33 ++++ .../publish-config-overrides.spec.ts | 182 ++++++++++++++++++ .../src/lib/override-publish-config.ts | 43 +++++ packages/publish/src/publish-command.ts | 16 +- 17 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 __fixtures__/normal-publish-config/lerna.json create mode 100644 __fixtures__/normal-publish-config/package.json create mode 100644 __fixtures__/normal-publish-config/packages/package-1/package.json create mode 100644 __fixtures__/normal-publish-config/packages/package-2/package.json create mode 100644 __fixtures__/normal-publish-config/packages/package-3/package.json create mode 100644 packages/publish/src/__tests__/publish-config-overrides.spec.ts create mode 100644 packages/publish/src/lib/override-publish-config.ts diff --git a/__fixtures__/normal-publish-config/lerna.json b/__fixtures__/normal-publish-config/lerna.json new file mode 100644 index 00000000..586a8110 --- /dev/null +++ b/__fixtures__/normal-publish-config/lerna.json @@ -0,0 +1,8 @@ +{ + "version": "1.0.0", + "command": { + "publish": { + "publishConfigOverrides": true + } + } +} diff --git a/__fixtures__/normal-publish-config/package.json b/__fixtures__/normal-publish-config/package.json new file mode 100644 index 00000000..3de58462 --- /dev/null +++ b/__fixtures__/normal-publish-config/package.json @@ -0,0 +1,3 @@ +{ + "name": "normal" +} diff --git a/__fixtures__/normal-publish-config/packages/package-1/package.json b/__fixtures__/normal-publish-config/packages/package-1/package.json new file mode 100644 index 00000000..5151d3b6 --- /dev/null +++ b/__fixtures__/normal-publish-config/packages/package-1/package.json @@ -0,0 +1,11 @@ +{ + "name": "package-1", + "version": "1.0.0", + "main": "./src/index.ts", + "typings": "./src/index.d.ts", + "publishConfig": { + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "access": "public" + } +} diff --git a/__fixtures__/normal-publish-config/packages/package-2/package.json b/__fixtures__/normal-publish-config/packages/package-2/package.json new file mode 100644 index 00000000..ff0dec38 --- /dev/null +++ b/__fixtures__/normal-publish-config/packages/package-2/package.json @@ -0,0 +1,25 @@ +{ + "name": "package-2", + "version": "1.0.0", + "dependencies": { + "package-1": "^1.0.0" + }, + "typesVersions": { + "*": { + "*": ["origin"] + } + }, + "publishConfig": { + "bin": "./build/bin.js", + "browser": "./build/browser.js", + "module": "./build/index.mjs", + "exports": { + "package-b": "dist/package-b.js" + }, + "typesVersions": { + "*": { + "*": ["overriden"] + } + } + } +} diff --git a/__fixtures__/normal-publish-config/packages/package-3/package.json b/__fixtures__/normal-publish-config/packages/package-3/package.json new file mode 100644 index 00000000..c064e402 --- /dev/null +++ b/__fixtures__/normal-publish-config/packages/package-3/package.json @@ -0,0 +1,10 @@ +{ + "name": "package-3", + "version": "1.0.0", + "peerDependencies": { + "package-2": "^1.0.0" + }, + "devDependencies": { + "package-2": "^1.0.0" + } +} diff --git a/packages/cli/schemas/lerna-schema.json b/packages/cli/schemas/lerna-schema.json index 3744c159..59802bde 100644 --- a/packages/cli/schemas/lerna-schema.json +++ b/packages/cli/schemas/lerna-schema.json @@ -364,6 +364,9 @@ "otp": { "$ref": "#/$defs/commandOptions/publish/otp" }, + "publishConfigOverrides": { + "$ref": "#/$defs/commandOptions/publish/publishConfigOverrides" + }, "registry": { "$ref": "#/$defs/commandOptions/shared/registry" }, @@ -1231,6 +1234,10 @@ "type": "string", "description": "During `lerna publish`, supply a one-time password for publishing with two-factor authentication." }, + "publishConfigOverrides": { + "type": "boolean", + "description": "apply publishConfig overrides." + }, "removePackageFields": { "type": "array", "items": { diff --git a/packages/cli/src/cli-commands/cli-publish-commands.ts b/packages/cli/src/cli-commands/cli-publish-commands.ts index bf7bfe21..97558584 100644 --- a/packages/cli/src/cli-commands/cli-publish-commands.ts +++ b/packages/cli/src/cli-commands/cli-publish-commands.ts @@ -86,6 +86,15 @@ export default { type: 'string', requiresArg: true, }, + 'no-publish-config-overrides': { + // proxy for --publish-config-overrides + hidden: true, + type: 'boolean', + }, + 'publish-config-overrides': { + describe: 'apply publishConfig overrides.', + type: 'boolean', + }, registry: { describe: 'Use the specified registry for all npm client operations.', type: 'string', diff --git a/packages/cli/src/lerna-entry.ts b/packages/cli/src/lerna-entry.ts index 6e6328fd..7ea36c8a 100644 --- a/packages/cli/src/lerna-entry.ts +++ b/packages/cli/src/lerna-entry.ts @@ -1,5 +1,6 @@ import loadJsonFile from 'load-json-file'; import path from 'path'; +import { JsonValue } from '@lerna-lite/core'; import changedCmd from './cli-commands/cli-changed-commands'; import diffCmd from './cli-commands/cli-diff-commands'; @@ -12,14 +13,8 @@ import runCmd from './cli-commands/cli-run-commands'; import versionCmd from './cli-commands/cli-version-commands'; import cli from './lerna-cli'; -interface JsonValue { - [dep: string]: string | number; -} - export function lerna(argv: any[]) { - const cliPkg = loadJsonFile.sync<{ [dep: string]: string | Array }>( - path.join(__dirname, '../', 'package.json') - ); + const cliPkg = loadJsonFile.sync<{ [dep: string]: JsonValue }>(path.join(__dirname, '../', 'package.json')); const context = { lernaVersion: (cliPkg?.version ?? '') as string, }; diff --git a/packages/core/src/models/command-options.ts b/packages/core/src/models/command-options.ts index 0c68d964..78cd7752 100644 --- a/packages/core/src/models/command-options.ts +++ b/packages/core/src/models/command-options.ts @@ -143,6 +143,9 @@ export interface PublishCommandOption extends VersionCommandOption { /** Supply a one-time password for publishing with two-factor authentication. */ otp?: string; + /** apply publishConfig overrides. */ + publishConfigOverrides?: boolean; + /** Use the specified registry for all npm client operations. */ registry?: string; diff --git a/packages/core/src/models/interfaces.ts b/packages/core/src/models/interfaces.ts index b241ea22..2f3b5953 100644 --- a/packages/core/src/models/interfaces.ts +++ b/packages/core/src/models/interfaces.ts @@ -7,6 +7,11 @@ import npa from 'npm-package-arg'; import { Package } from '../package'; import { InitCommandOption, PublishCommandOption, RunCommandOption, VersionCommandOption } from './command-options'; +export type JsonObject = { [Key in string]: JsonValue } & { [Key in string]?: JsonValue | undefined }; +export type JsonArray = JsonValue[]; +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; + export type VersioningStrategy = 'fixed' | 'independent'; export type ChangelogType = 'fixed' | 'independent' | 'root'; export type ChangelogPresetConfig = string | { name: string; [key: string]: unknown }; diff --git a/packages/core/src/package.ts b/packages/core/src/package.ts index c78ccff3..c76ce9cd 100644 --- a/packages/core/src/package.ts +++ b/packages/core/src/package.ts @@ -127,6 +127,11 @@ export class Package { return path.join(this.location, 'node_modules', '.bin'); } + /** alias to pkg getter (to avoid calling duplicate prop like `node.pkg.pkg` in which node is PackageGraphNode) */ + get manifest(): RawManifest { + return this[PKG]; + } + get manifestLocation(): string { return path.join(this.location, 'package.json'); } diff --git a/packages/core/src/utils/__tests__/object-utils.spec.ts b/packages/core/src/utils/__tests__/object-utils.spec.ts index 17392bbc..240821be 100644 --- a/packages/core/src/utils/__tests__/object-utils.spec.ts +++ b/packages/core/src/utils/__tests__/object-utils.spec.ts @@ -1,7 +1,7 @@ import cloneDeep from 'clone-deep'; import npmlog from 'npmlog'; -import { deleteComplexObjectProp, getComplexObjectValue } from '../object-utils'; +import { deleteComplexObjectProp, getComplexObjectValue, isEmpty } from '../object-utils'; describe('deleteComplexObjectProp method', () => { let obj = {}; @@ -77,3 +77,25 @@ describe('getComplexObjectValue method', () => { expect(output).toEqual('Broadway'); }); }); + +describe('isEmpty method', () => { + it('should return true when input is an empty object', () => { + const output = isEmpty({}); + expect(output).toBe(true); + }); + + it('should return true when input is null', () => { + const output = isEmpty(null as any); + expect(output).toBe(true); + }); + + it('should return true when input is undefined', () => { + const output = isEmpty(undefined as any); + expect(output).toBe(true); + }); + + it('should return false when input has at least one property', () => { + const output = isEmpty({ hello: 'world' }); + expect(output).toBe(false); + }); +}); diff --git a/packages/core/src/utils/object-utils.ts b/packages/core/src/utils/object-utils.ts index 4f18f9f5..2044dfec 100644 --- a/packages/core/src/utils/object-utils.ts +++ b/packages/core/src/utils/object-utils.ts @@ -37,3 +37,11 @@ export function getComplexObjectValue(object: any, path: string): T { } return path.split('.').reduce((obj, prop) => obj?.[prop], object); } + +/** + * Check if an object is empty + * @returns {Boolean} + */ +export function isEmpty(obj: object) { + return !obj || Object.keys(obj).length === 0; +} diff --git a/packages/publish/README.md b/packages/publish/README.md index 776afc44..ab62393e 100644 --- a/packages/publish/README.md +++ b/packages/publish/README.md @@ -91,6 +91,7 @@ This is useful when a previous `lerna publish` failed to publish all packages to - [`--temp-tag`](#--temp-tag) - [`--verify-access`](#--verify-access) - [`--yes`](#--yes) + - [`publishConfig` Overrides](#publishconfig-overrides) - [`workspace:` protocol](#workspace-protocol) - [`--workspace-strict-match (default)`](#with---workspace-strict-match-default) - [`--no-workspace-strict-match`](#with---no-workspace-strict-match-deprecated) @@ -423,6 +424,38 @@ This _non-standard_ field allows you to customize the published subdirectory jus } ``` +## `publishConfig` Overrides +Certain fields defined in `publishConfig` can be used to override other fields in the manifest before the package gets published. As per pnpm [`publishConfig`](https://pnpm.io/package_json#publishconfig) documentation, you can override any of these fields: + +- `bin`, `browser`, `cpu`, `esnext`, `es2015`, `exports`, `imports`, `libc`, `main`, `module`, `os`, `type`, `types`, `typings`, `typesVersions`, `umd:main`, `unpkg` + +> **Note** this option is enabled by default but can be disabled bia `lerna publish --no-publish-config-overrides` or (`"publishConfigOverrides": false` in `lerna.json`) + +For instance, the following package.json: + +```json +{ + "name": "foo", + "version": "1.0.0", + "main": "src/index.ts", + "publishConfig": { + "main": "lib/index.js", + "typings": "lib/index.d.ts" + } +} +``` + +Will be published as: + +```json +{ + "name": "foo", + "version": "1.0.0", + "main": "lib/index.js", + "typings": "lib/index.d.ts" +} +``` + ## Lifecycle Scripts diff --git a/packages/publish/src/__tests__/publish-config-overrides.spec.ts b/packages/publish/src/__tests__/publish-config-overrides.spec.ts new file mode 100644 index 00000000..ad381e92 --- /dev/null +++ b/packages/publish/src/__tests__/publish-config-overrides.spec.ts @@ -0,0 +1,182 @@ +// FIXME: better mock for version command +jest.mock('../../../version/dist/lib/git-push', () => + jest.requireActual('../../../version/src/lib/__mocks__/git-push') +); +jest.mock('../../../version/dist/lib/is-anything-committed', () => + jest.requireActual('../../../version/src/lib/__mocks__/is-anything-committed') +); +jest.mock('../../../version/dist/lib/is-behind-upstream', () => + jest.requireActual('../../../version/src/lib/__mocks__/is-behind-upstream') +); +jest.mock('../../../version/dist/lib/remote-branch-exists', () => + jest.requireActual('../../../version/src/lib/__mocks__/remote-branch-exists') +); + +// mocked modules of @lerna-lite/core +jest.mock('@lerna-lite/core', () => ({ + ...jest.requireActual('@lerna-lite/core'), // return the other real methods, below we'll mock specific methods + Command: jest.requireActual('../../../core/src/command').Command, + conf: jest.requireActual('../../../core/src/command').conf, + collectUpdates: jest.requireActual('../../../core/src/__mocks__/collect-updates').collectUpdates, + throwIfUncommitted: jest.requireActual('../../../core/src/__mocks__/check-working-tree').throwIfUncommitted, + getOneTimePassword: () => Promise.resolve('654321'), + logOutput: jest.requireActual('../../../core/src/__mocks__/output').logOutput, + promptConfirmation: jest.requireActual('../../../core/src/__mocks__/prompt').promptConfirmation, + promptSelectOne: jest.requireActual('../../../core/src/__mocks__/prompt').promptSelectOne, + promptTextInput: jest.requireActual('../../../core/src/__mocks__/prompt').promptTextInput, +})); + +// also point to the local publish command so that all mocks are properly used even by the command-runner +jest.mock('@lerna-lite/publish', () => jest.requireActual('../publish-command')); + +// local modules _must_ be explicitly mocked +jest.mock('../lib/get-packages-without-license', () => + jest.requireActual('../lib/__mocks__/get-packages-without-license') +); +jest.mock('../lib/verify-npm-package-access', () => jest.requireActual('../lib/__mocks__/verify-npm-package-access')); +jest.mock('../lib/get-npm-username', () => jest.requireActual('../lib/__mocks__/get-npm-username')); +jest.mock('../lib/get-two-factor-auth-required', () => + jest.requireActual('../lib/__mocks__/get-two-factor-auth-required') +); +jest.mock('../lib/pack-directory', () => jest.requireActual('../lib/__mocks__/pack-directory')); +jest.mock('../lib/npm-publish', () => jest.requireActual('../lib/__mocks__/npm-publish')); + +import fs from 'fs-extra'; +import path from 'path'; + +// mocked modules +import writePkg from 'write-pkg'; + +// helpers +import { gitAdd } from '@lerna-test/helpers'; +import { gitTag } from '@lerna-test/helpers'; +import { gitCommit } from '@lerna-test/helpers'; +import { initFixtureFactory } from '@lerna-test/helpers'; +const initFixture = initFixtureFactory(__dirname); + +// test command +import { PublishCommand } from '../index'; + +import yargParser from 'yargs-parser'; +import { PublishCommandOption } from '@lerna-lite/core'; + +const createArgv = (cwd, ...args) => { + args.unshift('publish'); + if (args.length > 0 && args[1]?.length > 0 && !args[1].startsWith('-')) { + args[1] = `--bump=${args[1]}`; + } + const parserArgs = args.join(' '); + const argv = yargParser(parserArgs); + argv['$0'] = cwd; + return argv as unknown as PublishCommandOption; +}; + +describe('publishConfig overrides', () => { + const setupChanges = async (cwd, pkgRoot = 'packages') => { + await fs.outputFile(path.join(cwd, `${pkgRoot}/package-1/hello.js`), 'world'); + await gitAdd(cwd, '.'); + await gitCommit(cwd, 'setup'); + }; + + it('overrides npm publish with publishConfig that are valid and leave fields that are not in the whitelist to be untouched and remain in publishConfig', async () => { + const cwd = await initFixture('normal-publish-config'); + + await gitTag(cwd, 'v1.0.0'); + await setupChanges(cwd); + await new PublishCommand(createArgv(cwd, '--bump', 'patch', '--yes')); + + expect((writePkg as any).updatedVersions()).toEqual({ + 'package-1': '1.0.1', + 'package-2': '1.0.1', + 'package-3': '1.0.1', + }); + + expect((writePkg as any).updatedManifest('package-1')).toEqual({ + gitHead: expect.any(String), + name: 'package-1', + main: 'dist/index.js', + publishConfig: { access: 'public' }, + typings: 'dist/index.d.ts', + version: '1.0.1', + }); + + expect((writePkg as any).updatedManifest('package-2')).toEqual({ + gitHead: expect.any(String), + name: 'package-2', + bin: './build/bin.js', + browser: './build/browser.js', + module: './build/index.mjs', + exports: { + 'package-b': 'dist/package-b.js', + }, + typesVersions: { + '*': { + '*': ['overriden'], + }, + }, + dependencies: { + 'package-1': '^1.0.1', + }, + version: '1.0.1', + }); + // publishConfig should be removed from package-2 since every fields were used as overrides + expect((writePkg as any).updatedManifest('package-2')).not.toEqual( + expect.objectContaining({ publishConfig: expect.anything() }) + ); + + expect((writePkg as any).updatedManifest('package-3').publishConfig).toBeUndefined(); + }); + + it('should not override anything and leave publishConfig untouched when --no-publish-config-overrides is provided', async () => { + const cwd = await initFixture('normal-publish-config'); + + await gitTag(cwd, 'v1.0.0'); + await setupChanges(cwd); + await new PublishCommand(createArgv(cwd, '--bump', 'patch', '--yes', '--no-publish-config-overrides')); + + expect((writePkg as any).updatedVersions()).toEqual({ + 'package-1': '1.0.1', + 'package-2': '1.0.1', + 'package-3': '1.0.1', + }); + + expect((writePkg as any).updatedManifest('package-1')).toEqual({ + gitHead: expect.any(String), + name: 'package-1', + main: './src/index.ts', + publishConfig: { + main: 'dist/index.js', + typings: 'dist/index.d.ts', + access: 'public', + }, + typings: './src/index.d.ts', + version: '1.0.1', + }); + expect((writePkg as any).updatedManifest('package-2')).toEqual({ + gitHead: expect.any(String), + name: 'package-2', + dependencies: { + 'package-1': '^1.0.1', + }, + typesVersions: { + '*': { + '*': ['origin'], + }, + }, + publishConfig: { + bin: './build/bin.js', + browser: './build/browser.js', + module: './build/index.mjs', + exports: { + 'package-b': 'dist/package-b.js', + }, + typesVersions: { + '*': { + '*': ['overriden'], + }, + }, + }, + version: '1.0.1', + }); + }); +}); diff --git a/packages/publish/src/lib/override-publish-config.ts b/packages/publish/src/lib/override-publish-config.ts new file mode 100644 index 00000000..2ad498d5 --- /dev/null +++ b/packages/publish/src/lib/override-publish-config.ts @@ -0,0 +1,43 @@ +import { isEmpty, JsonValue, RawManifest } from '@lerna-lite/core'; + +// manifest fields that may make sense to overwrite +const PUBLISH_CONFIG_WHITELIST = new Set([ + 'bin', + 'browser', + 'cpu', + 'esnext', + 'es2015', + 'exports', + 'imports', + 'libc', + 'main', + 'module', + 'os', + 'type', + 'types', + 'typings', + 'typesVersions', + 'umd:main', + 'unpkg', +]); + +/** + * It is possible to override some fields in the manifest before the package is published, we will use the same code as pnpm + * @see https://github.com/pnpm/pnpm/blob/main/packages/exportable-manifest/src/overridePublishConfig.ts + */ +export function overridePublishConfig(manifest: RawManifest) { + const publishConfig = manifest?.publishConfig as { [dep: string]: JsonValue }; + + if (publishConfig) { + Object.entries(publishConfig) + .filter(([key]) => PUBLISH_CONFIG_WHITELIST.has(key)) + .forEach(([key, value]) => { + manifest[key] = value; + delete publishConfig[key]; + }); + + if (isEmpty(publishConfig)) { + delete manifest.publishConfig; + } + } +} diff --git a/packages/publish/src/publish-command.ts b/packages/publish/src/publish-command.ts index dc5dd948..f2d4019c 100644 --- a/packages/publish/src/publish-command.ts +++ b/packages/publish/src/publish-command.ts @@ -44,6 +44,7 @@ import { packDirectory } from './lib/pack-directory'; import { npmPublish } from './lib/npm-publish'; import { logPacked } from './lib/log-packed'; import { add, remove } from './lib/npm-dist-tag'; +import { overridePublishConfig } from './lib/override-publish-config'; import { removeTempLicenses } from './lib/remove-temp-licenses'; import { createTempLicenses } from './lib/create-temp-licenses'; import { getPackagesWithoutLicense } from './lib/get-packages-without-license'; @@ -275,6 +276,9 @@ export class PublishCommand extends Command { await this.removePackageProperties(); } + if (this.options.publishConfigOverrides !== false) { + await this.applyPublishConfigOverrides(); + } await this.annotateGitHead(); await this.serializeChanges(); await this.packUpdated(); @@ -590,6 +594,16 @@ export class PublishCommand extends Command { }); } + /** + * It is possible to override some fields in the manifest before the package is packed + * @see https://pnpm.io/package_json#publishconfig + * @returns + */ + applyPublishConfigOverrides() { + // potentially apply any packages that might have publishConfig overrides + return pMap(this.updates, (node) => overridePublishConfig(node.pkg.manifest)); + } + resolveLocalDependencyLinks() { // resolve relative file: links to their actual version range const updatesWithLocalLinks = this.updates.filter((node: PackageGraphNode) => @@ -707,7 +721,7 @@ export class PublishCommand extends Command { return pMap(this.updates, (node: PackageGraphNode) => { if (Array.isArray(removePackageFields)) { for (const removeField of removePackageFields) { - deleteComplexObjectProp(node.pkg.pkg, removeField, `"${node.pkg.name}" package`); + deleteComplexObjectProp(node.pkg.manifest, removeField, `"${node.pkg.name}" package`); } } });