diff --git a/commands/publish/README.md b/commands/publish/README.md index f86b27c7812..b5c282c6e07 100644 --- a/commands/publish/README.md +++ b/commands/publish/README.md @@ -65,6 +65,7 @@ This is useful when a previous `lerna publish` failed to publish all packages to - [`--tag-version-prefix`](#--tag-version-prefix) - [`--temp-tag`](#--temp-tag) - [`--yes`](#--yes) +- [`--summary-file `](#--summary-file) ### `--canary` @@ -291,6 +292,27 @@ lerna publish --canary --yes When run with this flag, `lerna publish` will skip all confirmation prompts. Useful in [Continuous integration (CI)](https://en.wikipedia.org/wiki/Continuous_integration) to automatically answer the publish confirmation prompt. +### `--summary-file` + +```sh +lerna publish --canary --yes --summary-file ./output.json +``` + +When run with this flag, once a successfully publish it will create a json summary report(see below for an example). + +```json +[ + { + "packageName": "package1", + "version": "v1.0.1-alpha" + }, + { + "packageName": "package2", + "version": "v2.0.1-alpha" + } +] +``` + ## Deprecated Options ### `--skip-npm` diff --git a/commands/publish/__tests__/publish-command.test.js b/commands/publish/__tests__/publish-command.test.js index 75a50df3e93..a50e79df5ab 100644 --- a/commands/publish/__tests__/publish-command.test.js +++ b/commands/publish/__tests__/publish-command.test.js @@ -34,6 +34,8 @@ const initFixture = require("@lerna-test/init-fixture")(__dirname); const path = require("path"); const fs = require("fs-extra"); +const fsmain = require("fs"); + // file under test const lernaPublish = require("@lerna-test/command-runner")(require("../command")); @@ -306,6 +308,31 @@ Map { }); }); + describe("--summary-file", () => { + it("skips creating the summary file", async () => { + const cwd = await initFixture("normal"); + const fsSpy = jest.spyOn(fs, "writeFileSync"); + await lernaPublish(cwd); + + expect(fsSpy).not.toHaveBeenCalled(); + }); + + it("creates the summary file", async () => { + const cwd = await initFixture("normal"); + const fsSpy = jest.spyOn(fsmain, "writeFileSync"); + await lernaPublish(cwd)("--summary-file", "./output.json"); + + const expectedJsonResponse = [ + { packageName: "package-1", version: "1.0.1" }, + { packageName: "package-2", version: "1.0.1" }, + { packageName: "package-3", version: "1.0.1" }, + { packageName: "package-4", version: "1.0.1" }, + ]; + expect(fsSpy).toHaveBeenCalled(); + expect(fsSpy).toHaveBeenCalledWith("./output.json", JSON.stringify(expectedJsonResponse)); + }); + }); + describe("--no-verify-access", () => { it("skips package access verification", async () => { const cwd = await initFixture("normal"); diff --git a/commands/publish/command.js b/commands/publish/command.js index ec2d3e9b0d5..39f2b6a50b2 100644 --- a/commands/publish/command.js +++ b/commands/publish/command.js @@ -110,6 +110,11 @@ exports.builder = (yargs) => { hidden: true, type: "boolean", }, + "summary-file": { + // Json output. + hidden: true, + type: "string", + }, // y: { // describe: "Skip all confirmation prompts.", // alias: "yes", diff --git a/commands/publish/index.js b/commands/publish/index.js index 16d12a2c883..a6d357cbe4e 100644 --- a/commands/publish/index.js +++ b/commands/publish/index.js @@ -1,6 +1,7 @@ "use strict"; const os = require("os"); +const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const pMap = require("p-map"); @@ -154,10 +155,10 @@ class PublishCommand extends Command { // don't execute recursively if run from a poorly-named script this.runRootLifecycle = /^(pre|post)?publish$/.test(process.env.npm_lifecycle_event) - ? (stage) => { + ? stage => { this.logger.warn("lifecycle", "Skipping root %j because it has already been called", stage); } - : (stage) => this.runPackageLifecycle(this.project.manifest, stage); + : stage => this.runPackageLifecycle(this.project.manifest, stage); let chain = Promise.resolve(); @@ -171,7 +172,7 @@ class PublishCommand extends Command { chain = chain.then(() => versionCommand(this.argv)); } - return chain.then((result) => { + return chain.then(result => { if (!result) { // early return from nested VersionCommand return false; @@ -185,10 +186,10 @@ class PublishCommand extends Command { } // (occasionally) redundant private filtering necessary to handle nested VersionCommand - this.updates = result.updates.filter((node) => !node.pkg.private); + this.updates = result.updates.filter(node => !node.pkg.private); this.updatesVersions = new Map(result.updatesVersions); - this.packagesToPublish = this.updates.map((node) => node.pkg); + this.packagesToPublish = this.updates.map(node => node.pkg); if (this.options.contents) { // globally override directory to publish @@ -237,10 +238,29 @@ class PublishCommand extends Command { return chain.then(() => { const count = this.packagesToPublish.length; - const message = this.packagesToPublish.map((pkg) => ` - ${pkg.name}@${pkg.version}`); output("Successfully published:"); - output(message.join(os.EOL)); + + if (this.options.summaryFile) { + // create a json object and output it to a file location. + const filePath = this.options.summaryFile || "./output.json"; + const jsonObject = this.packagesToPublish.map(pkg => { + return { + packageName: pkg.name, + version: pkg.version, + }; + }); + output(jsonObject); + try { + fs.writeFileSync(filePath, JSON.stringify(jsonObject)); + output("Locate Summary Report Here: ", filePath); + } catch (error) { + output("Failed to create the summary report", error); + } + } else { + const message = this.packagesToPublish.map(pkg => ` - ${pkg.name}@${pkg.version}`); + output(message.join(os.EOL)); + } this.logger.success("published", "%d %s", count, count === 1 ? "package" : "packages"); }); @@ -259,7 +279,7 @@ class PublishCommand extends Command { chain = chain.then(() => this.verifyWorkingTreeClean()); chain = chain.then(() => getCurrentTags(this.execOpts, matchingPattern)); - chain = chain.then((taggedPackageNames) => { + chain = chain.then(taggedPackageNames => { if (!taggedPackageNames.length) { this.logger.notice("from-git", "No tagged release found"); @@ -267,17 +287,17 @@ class PublishCommand extends Command { } if (this.project.isIndependent()) { - return taggedPackageNames.map((name) => this.packageGraph.get(name)); + return taggedPackageNames.map(name => this.packageGraph.get(name)); } return getTaggedPackages(this.packageGraph, this.project.rootPath, this.execOpts); }); // private packages are never published, full stop. - chain = chain.then((updates) => updates.filter((node) => !node.pkg.private)); + chain = chain.then(updates => updates.filter(node => !node.pkg.private)); - return chain.then((updates) => { - const updatesVersions = updates.map((node) => [node.name, node.version]); + return chain.then(updates => { + const updatesVersions = updates.map(node => [node.name, node.version]); return { updates, @@ -293,7 +313,7 @@ class PublishCommand extends Command { // attempting to publish a release with local changes is not allowed chain = chain .then(() => this.verifyWorkingTreeClean()) - .catch((err) => { + .catch(err => { // an execa error is thrown when git suffers a fatal error (such as no git repository present) if (err.failed && /git describe/.test(err.command)) { // (we tried) @@ -307,7 +327,7 @@ class PublishCommand extends Command { // private packages are already omitted by getUnpublishedPackages() chain = chain.then(() => getUnpublishedPackages(this.packageGraph, this.conf.snapshot)); - chain = chain.then((unpublished) => { + chain = chain.then(unpublished => { if (!unpublished.length) { this.logger.notice("from-package", "No unpublished release found"); } @@ -315,8 +335,8 @@ class PublishCommand extends Command { return unpublished; }); - return chain.then((updates) => { - const updatesVersions = updates.map((node) => [node.name, node.version]); + return chain.then(updates => { + const updatesVersions = updates.map(node => [node.name, node.version]); return { updates, @@ -352,10 +372,10 @@ class PublishCommand extends Command { forcePublish, includeMergedTags, // private packages are never published, don't bother describing their refs. - }).filter((node) => !node.pkg.private) + }).filter(node => !node.pkg.private) ); - const makeVersion = (fallback) => ({ lastVersion = fallback, refCount, sha }) => { + const makeVersion = fallback => ({ lastVersion = fallback, refCount, sha }) => { // the next version is bumped without concern for preid or current index const nextVersion = semver.inc(lastVersion.replace(this.tagPrefix, ""), release.replace("pre", "")); @@ -366,8 +386,8 @@ class PublishCommand extends Command { if (this.project.isIndependent()) { // each package is described against its tags only - chain = chain.then((updates) => - pMap(updates, (node) => + chain = chain.then(updates => + pMap(updates, node => describeRef( { match: `${node.name}@*`, @@ -377,15 +397,15 @@ class PublishCommand extends Command { ) // an unpublished package will have no reachable git tag .then(makeVersion(node.version)) - .then((version) => [node.name, version]) - ).then((updatesVersions) => ({ + .then(version => [node.name, version]) + ).then(updatesVersions => ({ updates, updatesVersions, })) ); } else { // all packages are described against the last tag - chain = chain.then((updates) => + chain = chain.then(updates => describeRef( { match: `${this.tagPrefix}*.*.*`, @@ -395,8 +415,8 @@ class PublishCommand extends Command { ) // a repo with no tags should default to whatever lerna.json claims .then(makeVersion(this.project.version)) - .then((version) => updates.map((node) => [node.name, version])) - .then((updatesVersions) => ({ + .then(version => updates.map(node => [node.name, version])) + .then(updatesVersions => ({ updates, updatesVersions, })) @@ -413,7 +433,7 @@ class PublishCommand extends Command { confirmPublish() { const count = this.packagesToPublish.length; const message = this.packagesToPublish.map( - (pkg) => ` - ${pkg.name} => ${this.updatesVersions.get(pkg.name)}` + pkg => ` - ${pkg.name} => ${this.updatesVersions.get(pkg.name)}` ); output(""); @@ -432,11 +452,11 @@ class PublishCommand extends Command { prepareLicenseActions() { return Promise.resolve() .then(() => getPackagesWithoutLicense(this.project, this.packagesToPublish)) - .then((packagesWithoutLicense) => { + .then(packagesWithoutLicense => { if (packagesWithoutLicense.length && !this.project.licensePath) { this.packagesToBeLicensed = []; - const names = packagesWithoutLicense.map((pkg) => pkg.name); + const names = packagesWithoutLicense.map(pkg => pkg.name); const noun = names.length > 1 ? "Packages" : "Package"; const verb = names.length > 1 ? "are" : "is"; const list = @@ -480,7 +500,7 @@ class PublishCommand extends Command { // validate user has valid npm credentials first, // by far the most common form of failed execution chain = chain.then(() => getNpmUsername(this.conf.snapshot)); - chain = chain.then((username) => { + chain = chain.then(username => { // if no username was retrieved, don't bother validating if (username) { return verifyNpmPackageAccess(this.packagesToPublish, username, this.conf.snapshot); @@ -489,7 +509,7 @@ class PublishCommand extends Command { // read profile metadata to determine if account-level 2FA is enabled chain = chain.then(() => getTwoFactorAuthRequired(this.conf.snapshot)); - chain = chain.then((isRequired) => { + chain = chain.then(isRequired => { // notably, this still doesn't handle package-level 2FA requirements this.twoFactorAuthRequired = isRequired; }); @@ -499,7 +519,7 @@ class PublishCommand extends Command { } updateCanaryVersions() { - return pMap(this.updates, (node) => { + return pMap(this.updates, node => { node.pkg.set("version", this.updatesVersions.get(node.name)); for (const [depName, resolved] of node.localDependencies) { @@ -516,11 +536,11 @@ class PublishCommand extends Command { resolveLocalDependencyLinks() { // resolve relative file: links to their actual version range - const updatesWithLocalLinks = this.updates.filter((node) => - Array.from(node.localDependencies.values()).some((resolved) => resolved.type === "directory") + const updatesWithLocalLinks = this.updates.filter(node => + Array.from(node.localDependencies.values()).some(resolved => resolved.type === "directory") ); - return pMap(updatesWithLocalLinks, (node) => { + return pMap(updatesWithLocalLinks, node => { for (const [depName, resolved] of node.localDependencies) { // regardless of where the version comes from, we can't publish "file:../sibling-pkg" specs const depVersion = this.updatesVersions.get(depName) || this.packageGraph.get(depName).pkg.version; @@ -554,7 +574,7 @@ class PublishCommand extends Command { } serializeChanges() { - return pMap(this.packagesToPublish, (pkg) => pkg.serialize()); + return pMap(this.packagesToPublish, pkg => pkg.serialize()); } resetChanges() { @@ -566,9 +586,9 @@ class PublishCommand extends Command { }; const dirtyManifests = [this.project.manifest] .concat(this.packagesToPublish) - .map((pkg) => path.relative(cwd, pkg.manifestLocation)); + .map(pkg => path.relative(cwd, pkg.manifestLocation)); - return gitCheckout(dirtyManifests, gitOpts, this.execOpts).catch((err) => { + return gitCheckout(dirtyManifests, gitOpts, this.execOpts).catch(err => { this.logger.silly("EGITCHECKOUT", err.message); this.logger.notice("FYI", "Unable to reset working tree changes, this probably isn't a git repo."); }); @@ -590,7 +610,7 @@ class PublishCommand extends Command { removeTempLicensesOnError(error) { return Promise.resolve() .then(() => - removeTempLicenses(this.packagesToBeLicensed).catch((removeError) => { + removeTempLicenses(this.packagesToBeLicensed).catch(removeError => { this.logger.error( "licenses", "error removing temporary license files", @@ -612,7 +632,7 @@ class PublishCommand extends Command { return Promise.resolve() .then(() => getOneTimePassword("Enter OTP:")) - .then((otp) => { + .then(otp => { this.otpCache.otp = otp; }); } @@ -652,10 +672,10 @@ class PublishCommand extends Command { const opts = this.conf.snapshot; const mapper = pPipe( ...[ - this.options.requireScripts && ((pkg) => this.execScript(pkg, "prepublish")), + this.options.requireScripts && (pkg => this.execScript(pkg, "prepublish")), - (pkg) => - pulseTillDone(packDirectory(pkg, pkg.location, opts)).then((packed) => { + pkg => + pulseTillDone(packDirectory(pkg, pkg.location, opts)).then(packed => { tracker.verbose("packed", path.relative(this.project.rootPath, pkg.contents)); tracker.completeWork(1); @@ -673,7 +693,7 @@ class PublishCommand extends Command { chain = chain.then(() => removeTempLicenses(this.packagesToBeLicensed)); // remove temporary license files if _any_ error occurs _anywhere_ in the promise chain - chain = chain.catch((error) => this.removeTempLicensesOnError(error)); + chain = chain.catch(error => this.removeTempLicensesOnError(error)); if (!this.hasRootedLeaf) { chain = chain.then(() => this.runPackageLifecycle(this.project.manifest, "postpack")); @@ -702,7 +722,7 @@ class PublishCommand extends Command { const mapper = pPipe( ...[ - (pkg) => { + pkg => { const preDistTag = this.getPreDistTag(pkg); const tag = !this.options.tempTag && preDistTag ? preDistTag : opts.tag; const pkgOpts = Object.assign({}, opts, { tag }); @@ -717,7 +737,7 @@ class PublishCommand extends Command { }); }, - this.options.requireScripts && ((pkg) => this.execScript(pkg, "postpublish")), + this.options.requireScripts && (pkg => this.execScript(pkg, "postpublish")), ].filter(Boolean) ); @@ -741,14 +761,14 @@ class PublishCommand extends Command { let chain = Promise.resolve(); const opts = this.conf.snapshot; - const getDistTag = (publishConfig) => { + const getDistTag = publishConfig => { if (opts.tag === "latest" && publishConfig && publishConfig.tag) { return publishConfig.tag; } return opts.tag; }; - const mapper = (pkg) => { + const mapper = pkg => { const spec = `${pkg.name}@${pkg.version}`; const preDistTag = this.getPreDistTag(pkg); const distTag = preDistTag || getDistTag(pkg.get("publishConfig"));