diff --git a/docs/content/commands/npm-publish.md b/docs/content/commands/npm-publish.md index fc13e67222358..10e65f895ec5b 100644 --- a/docs/content/commands/npm-publish.md +++ b/docs/content/commands/npm-publish.md @@ -47,6 +47,13 @@ by specifying a different default registry or using a actually publishing to the registry. Reports the details of what would have been published. +* `[--workspaces]`: Enables workspace context while publishing. All + workspace packages will be published. + +* `[--workspace]`: Enables workspaces context and limits results to only + those specified by this config item. Only the packages in the + workspaces given will be published. + The publish will fail if the package name and version combination already exists in the specified registry. diff --git a/lib/publish.js b/lib/publish.js index b121cb3d36773..b33839903a266 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -8,13 +8,19 @@ const pacote = require('pacote') const npa = require('npm-package-arg') const npmFetch = require('npm-registry-fetch') -const flatten = require('./utils/config/flatten.js') const otplease = require('./utils/otplease.js') const { getContents, logTar } = require('./utils/tar.js') +const getWorkspaces = require('./workspaces/get-workspaces.js') + +// for historical reasons, publishConfig in package.json can contain ANY config +// keys that npm supports in .npmrc files and elsewhere. We *may* want to +// revisit this at some point, and have a minimal set that's a SemVer-major +// change that ought to get a RFC written on it. +const flatten = require('./utils/config/flatten.js') -// this is the only case in the CLI where we use the old full slow -// 'read-package-json' module, because we want to pull in all the -// defaults and metadata, like git sha's and default scripts and all that. +// this is the only case in the CLI where we want to use the old full slow +// 'read-package-json' module, because we want to pull in all the defaults and +// metadata, like git sha's and default scripts and all that. const readJson = util.promisify(require('read-package-json')) const BaseCommand = require('./base-command.js') @@ -30,7 +36,7 @@ class Publish extends BaseCommand { /* istanbul ignore next - see test/lib/load-all-commands.js */ static get params () { - return ['tag', 'access', 'dry-run'] + return ['tag', 'access', 'dry-run', 'workspace', 'workspaces'] } /* istanbul ignore next - see test/lib/load-all-commands.js */ @@ -44,6 +50,10 @@ class Publish extends BaseCommand { this.publish(args).then(() => cb()).catch(cb) } + execWorkspaces (args, filters, cb) { + this.publishWorkspaces(args, filters).then(() => cb()).catch(cb) + } + async publish (args) { if (args.length === 0) args = ['.'] @@ -56,6 +66,7 @@ class Publish extends BaseCommand { const dryRun = this.npm.config.get('dry-run') const json = this.npm.config.get('json') const defaultTag = this.npm.config.get('tag') + const silent = log.level === 'silent' if (semver.validRange(defaultTag)) throw new Error('Tag name must not be a valid SemVer range: ' + defaultTag.trim()) @@ -68,7 +79,7 @@ class Publish extends BaseCommand { let manifest = await this.getManifest(spec, opts) if (manifest.publishConfig) - Object.assign(opts, this.publishConfigToOpts(manifest.publishConfig)) + flatten(manifest.publishConfig, opts) // only run scripts for directory type publishes if (spec.type === 'directory') { @@ -77,7 +88,7 @@ class Publish extends BaseCommand { path: spec.fetchSpec, stdio: 'inherit', pkg: manifest, - banner: log.level !== 'silent', + banner: !silent, }) } @@ -89,7 +100,7 @@ class Publish extends BaseCommand { // note that publishConfig might have changed as well! manifest = await this.getManifest(spec, opts) if (manifest.publishConfig) - Object.assign(opts, this.publishConfigToOpts(manifest.publishConfig)) + flatten(manifest.publishConfig, opts) // note that logTar calls npmlog.notice(), so if we ARE in silent mode, // this will do nothing, but we still want it in the debuglog if it fails. @@ -114,7 +125,7 @@ class Publish extends BaseCommand { path: spec.fetchSpec, stdio: 'inherit', pkg: manifest, - banner: log.level !== 'silent', + banner: !silent, }) await runScript({ @@ -122,19 +133,43 @@ class Publish extends BaseCommand { path: spec.fetchSpec, stdio: 'inherit', pkg: manifest, - banner: log.level !== 'silent', + banner: !silent, }) } - const silent = log.level === 'silent' - if (!silent && json) - this.npm.output(JSON.stringify(pkgContents, null, 2)) - else if (!silent) - this.npm.output(`+ ${pkgContents.id}`) + if (!this.workspaces) { + if (!silent && json) + this.npm.output(JSON.stringify(pkgContents, null, 2)) + else if (!silent) + this.npm.output(`+ ${pkgContents.id}`) + } return pkgContents } + async publishWorkspaces (args, filters) { + // Suppresses JSON output in publish() so we can handle it here + this.workspaces = true + + const results = {} + const json = this.npm.config.get('json') + const silent = log.level === 'silent' + const workspaces = + await getWorkspaces(filters, { path: this.npm.localPrefix }) + for (const [name, workspace] of workspaces.entries()) { + const pkgContents = await this.publish([workspace]) + // This needs to be in-line w/ the rest of the output that non-JSON + // publish generates + if (!silent && !json) + this.npm.output(`+ ${pkgContents.id}`) + else + results[name] = pkgContents + } + + if (!silent && json) + this.npm.output(JSON.stringify(results, null, 2)) + } + // if it's a directory, read it from the file system // otherwise, get the full metadata from whatever it is getManifest (spec, opts) { @@ -142,16 +177,5 @@ class Publish extends BaseCommand { return readJson(`${spec.fetchSpec}/package.json`) return pacote.manifest(spec, { ...opts, fullMetadata: true }) } - - // for historical reasons, publishConfig in package.json can contain - // ANY config keys that npm supports in .npmrc files and elsewhere. - // We *may* want to revisit this at some point, and have a minimal set - // that's a SemVer-major change that ought to get a RFC written on it. - publishConfigToOpts (publishConfig) { - // create a new object that inherits from the config stack - // then squash the css-case into camelCase opts, like we do - // this is Object.assign()'ed onto the base npm.flatOptions - return flatten(publishConfig, {}) - } } module.exports = Publish diff --git a/tap-snapshots/test/lib/publish.js.test.cjs b/tap-snapshots/test/lib/publish.js.test.cjs index 172ed5b29f478..0d652e289a822 100644 --- a/tap-snapshots/test/lib/publish.js.test.cjs +++ b/tap-snapshots/test/lib/publish.js.test.cjs @@ -15,6 +15,109 @@ npm publish [] Options: [--tag ] [--access ] [--dry-run] +[-w|--workspace [-w|--workspace ...]] +[-ws|--workspaces] Run "npm help publish" for more info ` + +exports[`test/lib/publish.js TAP workspaces all workspaces > should output all publishes 1`] = ` +Array [ + "+ workspace-a@1.2.3-a", + "+ workspace-b@1.2.3-n", +] +` + +exports[`test/lib/publish.js TAP workspaces all workspaces > should publish all workspaces 1`] = ` +Array [ + Object { + "_id": "workspace-a@1.2.3-a", + "name": "workspace-a", + "readme": "ERROR: No README data found!", + "repository": Object { + "type": "git", + "url": "http://repo.workspace-a/", + }, + "version": "1.2.3-a", + }, + Object { + "_id": "workspace-b@1.2.3-n", + "bugs": Object { + "url": "https://github.com/npm/workspace-b/issues", + }, + "homepage": "https://github.com/npm/workspace-b#readme", + "name": "workspace-b", + "readme": "ERROR: No README data found!", + "repository": Object { + "type": "git", + "url": "git+https://github.com/npm/workspace-b.git", + }, + "version": "1.2.3-n", + }, +] +` + +exports[`test/lib/publish.js TAP workspaces json > should output all publishes as json 1`] = ` +Array [ + String( + { + "workspace-a": { + "id": "workspace-a@1.2.3-a" + }, + "workspace-b": { + "id": "workspace-b@1.2.3-n" + } + } + ), +] +` + +exports[`test/lib/publish.js TAP workspaces json > should publish all workspaces 1`] = ` +Array [ + Object { + "_id": "workspace-a@1.2.3-a", + "name": "workspace-a", + "readme": "ERROR: No README data found!", + "repository": Object { + "type": "git", + "url": "http://repo.workspace-a/", + }, + "version": "1.2.3-a", + }, + Object { + "_id": "workspace-b@1.2.3-n", + "bugs": Object { + "url": "https://github.com/npm/workspace-b/issues", + }, + "homepage": "https://github.com/npm/workspace-b#readme", + "name": "workspace-b", + "readme": "ERROR: No README data found!", + "repository": Object { + "type": "git", + "url": "git+https://github.com/npm/workspace-b.git", + }, + "version": "1.2.3-n", + }, +] +` + +exports[`test/lib/publish.js TAP workspaces one workspace > should output one publish 1`] = ` +Array [ + "+ workspace-a@1.2.3-a", +] +` + +exports[`test/lib/publish.js TAP workspaces one workspace > should publish given workspace 1`] = ` +Array [ + Object { + "_id": "workspace-a@1.2.3-a", + "name": "workspace-a", + "readme": "ERROR: No README data found!", + "repository": Object { + "type": "git", + "url": "http://repo.workspace-a/", + }, + "version": "1.2.3-a", + }, +] +` diff --git a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs index ad61dc7969ac4..9bcba775fd85b 100644 --- a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs +++ b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs @@ -697,6 +697,8 @@ All commands: Options: [--tag ] [--access ] [--dry-run] + [-w|--workspace [-w|--workspace ...]] + [-ws|--workspaces] Run "npm help publish" for more info diff --git a/test/lib/publish.js b/test/lib/publish.js index 57574b22a56ea..7aff7b08c1971 100644 --- a/test/lib/publish.js +++ b/test/lib/publish.js @@ -9,15 +9,12 @@ const fs = require('fs') const log = require('npmlog') log.level = 'silent' -// mock config const {definitions} = require('../../lib/utils/config') const defaults = Object.entries(definitions).reduce((defaults, [key, def]) => { defaults[key] = def.default return defaults }, {}) -const config = defaults - t.afterEach(() => log.level = 'silent') t.test('should publish with libnpmpublish, passing through flatOptions and respecting publishConfig.registry', (t) => { @@ -54,7 +51,6 @@ t.test('should publish with libnpmpublish, passing through flatOptions and respe }, }) const npm = mockNpm({ - config, flatOptions: { customValue: true, }, @@ -102,7 +98,7 @@ t.test('re-loads publishConfig.registry if added during script process', (t) => }, }, }) - const npm = mockNpm({ config }) + const npm = mockNpm() npm.config.getCredentialsByURI = (uri) => { t.same(uri, registry, 'gets credentials for expected registry') return { token: 'some.registry.token' } @@ -144,13 +140,13 @@ t.test('if loglevel=info and json, should not output package contents', (t) => { }, }) const npm = mockNpm({ - config: { ...config, json: true }, + config: { json: true }, output: () => { t.pass('output is called') }, }) npm.config.getCredentialsByURI = (uri) => { - t.same(uri, defaults.registry, 'gets credentials for expected registry') + t.same(uri, npm.config.get('registry'), 'gets credentials for expected registry') return { token: 'some.registry.token' } } const publish = new Publish(npm) @@ -190,7 +186,7 @@ t.test('if loglevel=silent and dry-run, should not output package contents or pu }, }) const npm = mockNpm({ - config: { ...config, 'dry-run': true }, + config: { 'dry-run': true }, output: () => { throw new Error('should not output in dry run mode') }, @@ -236,7 +232,7 @@ t.test('if loglevel=info and dry-run, should not publish, should log package con }, }) const npm = mockNpm({ - config: { ...config, 'dry-run': true }, + config: { 'dry-run': true }, output: () => { t.pass('output fn is called') }, @@ -270,7 +266,7 @@ t.test('throws when invalid tag', (t) => { const Publish = t.mock('../../lib/publish.js') const npm = mockNpm({ - config: { ...config, tag: '0.0.13' }, + config: { tag: '0.0.13' }, }) const publish = new Publish(npm) @@ -313,9 +309,9 @@ t.test('can publish a tarball', t => { }, }, }) - const npm = mockNpm({ config }) + const npm = mockNpm() npm.config.getCredentialsByURI = (uri) => { - t.same(uri, defaults.registry, 'gets credentials for expected registry') + t.same(uri, npm.config.get('registry'), 'gets credentials for expected registry') return { token: 'some.registry.token' } } const publish = new Publish(npm) @@ -331,9 +327,9 @@ t.test('can publish a tarball', t => { t.test('should check auth for default registry', t => { t.plan(2) const Publish = t.mock('../../lib/publish.js') - const npm = mockNpm({ config }) + const npm = mockNpm() npm.config.getCredentialsByURI = (uri) => { - t.same(uri, defaults.registry, 'gets credentials for expected registry') + t.same(uri, npm.config.get('registry'), 'gets credentials for expected registry') return {} } const publish = new Publish(npm) @@ -352,7 +348,6 @@ t.test('should check auth for configured registry', t => { const registry = 'https://some.registry' const Publish = t.mock('../../lib/publish.js') const npm = mockNpm({ - config, flatOptions: { registry }, }) npm.config.getCredentialsByURI = (uri) => { @@ -382,7 +377,6 @@ t.test('should check auth for scope specific registry', t => { const Publish = t.mock('../../lib/publish.js') const npm = mockNpm({ - config, flatOptions: { '@npm:registry': registry }, }) npm.config.getCredentialsByURI = (uri) => { @@ -419,7 +413,6 @@ t.test('should use auth for scope specific registry', t => { }, }) const npm = mockNpm({ - config, flatOptions: { '@npm:registry': registry }, }) npm.config.getCredentialsByURI = (uri) => { @@ -457,9 +450,7 @@ t.test('read registry only from publishConfig', t => { }, }, }) - const npm = mockNpm({ - config, - }) + const npm = mockNpm() npm.config.getCredentialsByURI = (uri) => { t.same(uri, registry, 'gets credentials for expected registry') return { token: 'some.registry.token' } @@ -525,3 +516,104 @@ t.test('able to publish after if encountered multiple configs', t => { t.end() }) }) + +t.test('workspaces', (t) => { + const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'my-cool-pkg', + version: '1.0.0', + workspaces: ['workspace-a', 'workspace-b', 'workspace-c'], + }, null, 2), + 'workspace-a': { + 'package.json': JSON.stringify({ + name: 'workspace-a', + version: '1.2.3-a', + repository: 'http://repo.workspace-a/', + }), + }, + 'workspace-b': { + 'package.json': JSON.stringify({ + name: 'workspace-b', + version: '1.2.3-n', + repository: 'https://github.com/npm/workspace-b', + }), + }, + 'workspace-c': JSON.stringify({ + 'package.json': { + name: 'workspace-n', + version: '1.2.3-n', + }, + }), + }) + + const publishes = [] + const outputs = [] + t.beforeEach(() => { + npm.config.set('json', false) + outputs.length = 0 + publishes.length = 0 + }) + const Publish = t.mock('../../lib/publish.js', { + '../../lib/utils/tar.js': { + getContents: (manifest) => ({ + id: manifest._id, + }), + logTar: () => {}, + }, + libnpmpublish: { + publish: (manifest, tarballData, opts) => { + publishes.push(manifest) + }, + }, + }) + const npm = mockNpm({ + output: (o) => { + outputs.push(o) + }, + }) + npm.localPrefix = testDir + npm.config.getCredentialsByURI = (uri) => { + return { token: 'some.registry.token' } + } + const publish = new Publish(npm) + + t.test('all workspaces', (t) => { + log.level = 'info' + publish.execWorkspaces([], [], (err) => { + t.notOk(err) + t.matchSnapshot(publishes, 'should publish all workspaces') + t.matchSnapshot(outputs, 'should output all publishes') + t.end() + }) + }) + + t.test('one workspace', t => { + log.level = 'info' + publish.execWorkspaces([], ['workspace-a'], (err) => { + t.notOk(err) + t.matchSnapshot(publishes, 'should publish given workspace') + t.matchSnapshot(outputs, 'should output one publish') + t.end() + }) + }) + + t.test('invalid workspace', t => { + publish.execWorkspaces([], ['workspace-x'], (err) => { + t.match(err, /No workspaces found/) + t.match(err, /workspace-x/) + t.end() + }) + }) + + t.test('json', t => { + log.level = 'info' + npm.config.set('json', true) + publish.execWorkspaces([], [], (err) => { + t.notOk(err) + t.matchSnapshot(publishes, 'should publish all workspaces') + t.matchSnapshot(outputs, 'should output all publishes as json') + t.end() + }) + }) + t.end() +})