np
Version:
A better `npm publish`
349 lines (293 loc) • 10.7 kB
JavaScript
import inquirer from 'inquirer';
import chalk from 'chalk';
import githubUrlFromGit from 'github-url-from-git';
import {htmlEscape} from 'escape-goat';
import isScoped from 'is-scoped';
import isInteractive from 'is-interactive';
import {execa} from 'execa';
import Version, {SEMVER_INCREMENTS} from './version.js';
import * as util from './util.js';
import * as git from './git-util.js';
import * as npm from './npm/util.js';
const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch) => {
const revision = fromLatestTag ? await git.latestTagOrFirstCommit() : await git.previousTagOrFirstCommit();
if (!revision) {
throw new Error('The package has not been published yet.');
}
const log = await git.commitLogFromRevision(revision);
if (!log) {
return {
hasCommits: false,
hasUnreleasedCommits: false,
releaseNotes() {},
};
}
let hasUnreleasedCommits = false;
let commitRangeText = `${revision}...${releaseBranch}`;
let commits = log.split('\n')
.map(commit => {
const splitIndex = commit.lastIndexOf(' ');
return {
message: commit.slice(0, splitIndex),
id: commit.slice(splitIndex + 1),
};
});
if (!fromLatestTag) {
const latestTag = await git.latestTag();
// Version bump commit created by np, following the semver specification.
const versionBumpCommitName = latestTag.match(/v\d+\.\d+\.\d+/) && latestTag.slice(1); // Name v1.0.1 becomes 1.0.1
const versionBumpCommitIndex = commits.findIndex(commit => commit.message === versionBumpCommitName);
if (versionBumpCommitIndex > 0) {
commitRangeText = `${revision}...${latestTag}`;
hasUnreleasedCommits = true;
}
if (await git.isHeadDetached()) {
commitRangeText = `${revision}...${latestTag}`;
}
// Get rid of unreleased commits and of the version bump commit.
commits = commits.slice(versionBumpCommitIndex + 1);
}
const history = commits.map(commit => {
const commitMessage = util.linkifyIssues(repoUrl, commit.message);
const commitId = util.linkifyCommit(repoUrl, commit.id);
return `- ${commitMessage} ${commitId}`;
}).join('\n');
const releaseNotes = nextTag => commits.map(commit =>
`- ${htmlEscape(commit.message)} ${commit.id}`,
).join('\n') + `\n\n---\n\n${repoUrl}/compare/${revision}...${nextTag}`;
const commitRange = util.linkifyCommitRange(repoUrl, commitRangeText);
console.log(`${chalk.bold('Commits:')}\n${history}\n\n${chalk.bold('Commit Range:')}\n${commitRange}\n\n${chalk.bold('Registry:')}\n${registryUrl}\n`);
return {
hasCommits: true,
hasUnreleasedCommits,
releaseNotes,
};
};
const checkNewFilesAndDependencies = async (package_, rootDirectory) => {
const newFiles = await util.getNewFiles(rootDirectory);
const newDependencies = await util.getNewDependencies(package_, rootDirectory);
const noNewUnpublishedFiles = !newFiles.unpublished || newFiles.unpublished.length === 0;
const noNewFirstTimeFiles = !newFiles.firstTime || newFiles.firstTime.length === 0;
const noNewFiles = noNewUnpublishedFiles && noNewFirstTimeFiles;
const noNewDependencies = !newDependencies || newDependencies.length === 0;
if (noNewFiles && noNewDependencies) {
return true;
}
const messages = [];
if (newFiles.unpublished.length > 0) {
messages.push(`The following new files will not be part of your published package:\n${util.groupFilesInFolders(newFiles.unpublished)}\n\nIf you intended to publish them, add them to the \`files\` field in package.json.`);
}
if (newFiles.firstTime.length > 0) {
messages.push(`The following new files will be published for the first time:\n${util.joinList(newFiles.firstTime)}\n\nPlease make sure only the intended files are listed.`);
}
if (newDependencies.length > 0) {
messages.push(`The following new dependencies will be part of your published package:\n${util.joinList(newDependencies)}\n\nPlease make sure these new dependencies are intentional.`);
}
if (!isInteractive()) {
console.log(messages.join('\n'));
return true;
}
const answers = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `${messages.join('\n')}\nContinue?`,
default: false,
}]);
return answers.confirm;
};
/**
@param {import('./cli-implementation.js').CLI['flags']} options
@param {{package_: import('read-pkg').NormalizedPackageJson; rootDirectory: string}} context
*/
const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { // eslint-disable-line complexity
const oldVersion = package_.version;
const extraBaseUrls = ['gitlab.com'];
const repoUrl = package_.repository && githubUrlFromGit(package_.repository.url, {extraBaseUrls});
const {stdout: registryUrl} = await execa(...packageManager.getRegistryCommand);
const releaseBranch = options.branch;
if (options.runPublish) {
await npm.checkIgnoreStrategy(package_, rootDirectory);
const answerIgnoredFiles = await checkNewFilesAndDependencies(package_, rootDirectory);
if (!answerIgnoredFiles) {
return {
...options,
confirm: answerIgnoredFiles,
};
}
}
if (options.releaseDraftOnly) {
console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(package_.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`);
} else {
const versionText = options.version
? chalk.dim(`(current: ${oldVersion}, next: ${new Version(oldVersion, options.version, {prereleasePrefix: await util.getPreReleasePrefix(packageManager)}).format()})`)
: chalk.dim(`(current: ${oldVersion})`);
console.log(`\nPublish a new version of ${chalk.bold.magenta(package_.name)} ${versionText}\n`);
}
const useLatestTag = !options.releaseDraftOnly;
const {hasCommits, hasUnreleasedCommits, releaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag, releaseBranch);
if (hasUnreleasedCommits && options.releaseDraftOnly) {
const answers = await inquirer.prompt({
confirm: {
type: 'confirm',
message: 'Unreleased commits found. They won\'t be included in the release draft. Continue?',
default: false,
},
});
if (!answers.confirm) {
return {
...options,
...answers,
};
}
}
// Non-interactive mode - return before prompting
if (options.version) {
return {
...options,
confirm: true,
repoUrl,
releaseNotes,
};
}
if (!hasCommits) {
const answers = await inquirer.prompt({
confirm: {
type: 'confirm',
message: 'No commits found since previous release, continue?',
default: false,
},
});
if (!answers.confirm) {
return {
...options,
...answers,
};
}
}
if (options.availability.isUnknown) {
if (!isScoped(package_.name)) {
throw new Error('Unknown availability, but package is not scoped. This shouldn\'t happen');
}
const answers = await inquirer.prompt({
confirm: {
type: 'confirm',
when: isScoped(package_.name) && options.runPublish,
message: `Failed to check availability of scoped repo name ${chalk.bold.magenta(package_.name)}. Do you want to try and publish it anyway?`,
default: false,
},
});
if (!answers.confirm) {
return {
...options,
...answers,
};
}
}
const needsPrereleaseTag = answers => (
options.runPublish
&& (answers.version?.isPrerelease() || answers.customVersion?.isPrerelease())
&& !options.tag
);
const alreadyPublicScoped = packageManager.id === 'yarn-berry' && options.runPublish && await util.getNpmPackageAccess(package_.name) === 'public';
// Note that inquirer question.when is a bit confusing. Only `false` will cause the question to be skipped.
// Any other value like `true` and `undefined` means ask the question.
// so we make sure to always return an explicit boolean here to make it less confusing
// see https://github.com/SBoudrias/Inquirer.js/pull/1340
const needToAskForPublish = (() => {
if (alreadyPublicScoped || !isScoped(package_.name) || !options.availability.isAvailable || options.availability.isUnknown || !options.runPublish) {
return false;
}
if (!package_.publishConfig) {
return true;
}
return package_.publishConfig.access !== 'restricted' && !npm.isExternalRegistry(package_);
})();
const answers = await inquirer.prompt({
version: {
type: 'list',
message: 'Select SemVer increment or specify new version',
pageSize: SEMVER_INCREMENTS.length + 2,
default: 0,
choices: [
...SEMVER_INCREMENTS.map(increment => ({ // TODO: prerelease prefix here too
name: `${increment} ${new Version(oldVersion, increment).format()}`,
value: increment,
})),
new inquirer.Separator(),
{
name: 'Other (specify)',
value: undefined,
},
],
filter: input => input ? new Version(oldVersion, input) : input,
},
customVersion: {
type: 'input',
message: 'Version',
when: answers => answers.version === undefined,
filter(input) {
if (SEMVER_INCREMENTS.includes(input)) {
throw new Error('Custom version should not be a SemVer increment.');
}
const version = new Version(oldVersion);
try {
// Version error handling does validation
version.setFrom(input);
} catch (error) {
if (error.message.includes('valid SemVer version')) {
throw new Error(`Custom version ${input} should be a valid SemVer version.`);
}
error.message = error.message.replace('New', 'Custom');
throw error;
}
return version;
},
},
tag: {
type: 'list',
message: 'How should this pre-release version be tagged in npm?',
when: answers => needsPrereleaseTag(answers),
async choices() {
const existingPrereleaseTags = await npm.prereleaseTags(package_.name);
return [
...existingPrereleaseTags,
new inquirer.Separator(),
{
name: 'Other (specify)',
value: undefined,
},
];
},
},
customTag: {
type: 'input',
message: 'Tag',
when: answers => answers.tag === undefined && needsPrereleaseTag(answers),
validate(input) {
if (input.length === 0) {
return 'Please specify a tag, for example, `next`.';
}
if (input.toLowerCase() === 'latest') {
return 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.';
}
return true;
},
},
publishScoped: {
type: 'confirm',
when: needToAskForPublish,
message: `This scoped repo ${chalk.bold.magenta(package_.name)} hasn't been published. Do you want to publish it publicly?`,
default: false,
},
});
return {
...options,
version: answers.version || answers.customVersion || options.version,
tag: answers.tag || answers.customTag || options.tag,
publishScoped: alreadyPublicScoped || answers.publishScoped,
confirm: true,
repoUrl,
releaseNotes,
};
};
export default ui;