From 0cff2b4680710ad78bb8abe1cb431dd1b06d6284 Mon Sep 17 00:00:00 2001 From: James Talmage Date: Thu, 29 Oct 2020 10:38:43 -0400 Subject: [PATCH 001/180] Prefer local install (#572) --- package.json | 1 + readme.md | 2 +- source/cli-implementation.js | 147 +++++++++++++++++++++++++++++++++ source/cli.js | 152 +++-------------------------------- 4 files changed, 160 insertions(+), 142 deletions(-) create mode 100755 source/cli-implementation.js diff --git a/package.json b/package.json index f2ca454d..56fd5fbe 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "github-url-from-git": "^1.5.0", "has-yarn": "^2.1.0", "hosted-git-info": "^3.0.0", + "import-local": "^3.0.2", "inquirer": "^7.0.0", "is-installed-globally": "^0.3.1", "is-scoped": "^2.1.0", diff --git a/readme.md b/readme.md index 2f4fc658..73e723b9 100644 --- a/readme.md +++ b/readme.md @@ -80,7 +80,7 @@ Run `np` without arguments to launch the interactive UI that guides you through ## Config -`np` can be configured both locally and globally. When using the global `np` binary, you can configure any of the CLI flags in either a `.np-config.js` or `.np-config.json` file in the home directory. When using the local `np` binary, for example, in a `npm run` script, you can configure `np` by setting the flags in either a top-level `np` field in `package.json` or in a `.np-config.js` or `.np-config.json` file in the project directory. +`np` can be configured both locally and globally. When using the global `np` binary, you can configure any of the CLI flags in either a `.np-config.js` or `.np-config.json` file in the home directory. When using the local `np` binary, for example, in a `npm run` script, you can configure `np` by setting the flags in either a top-level `np` field in `package.json` or in a `.np-config.js` or `.np-config.json` file in the project directory. If it exists, the local installation will always take precedence. This ensures any local config matches the version of `np` it was designed for. Currently, these are the flags you can configure: diff --git a/source/cli-implementation.js b/source/cli-implementation.js new file mode 100755 index 00000000..eb9e0190 --- /dev/null +++ b/source/cli-implementation.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node +'use strict'; +// eslint-disable-next-line import/no-unassigned-import +require('symbol-observable'); // Important: This needs to be first to prevent weird Observable incompatibilities +const logSymbols = require('log-symbols'); +const meow = require('meow'); +const updateNotifier = require('update-notifier'); +const hasYarn = require('has-yarn'); +const config = require('./config'); +const {isPackageNameAvailable} = require('./npm/util'); +const version = require('./version'); +const util = require('./util'); +const ui = require('./ui'); +const np = require('.'); + +const cli = meow(` + Usage + $ np + + Version can be: + ${version.SEMVER_INCREMENTS.join(' | ')} | 1.2.3 + + Options + --any-branch Allow publishing from any branch + --branch Name of the release branch (default: master) + --no-cleanup Skips cleanup of node_modules + --no-tests Skips tests + --yolo Skips cleanup and testing + --no-publish Skips publishing + --preview Show tasks without actually executing them + --tag Publish under a given dist-tag + --no-yarn Don't use Yarn + --contents Subdirectory to publish + --no-release-draft Skips opening a GitHub release draft + --test-script Name of npm run script to run tests before publishing (default: test) + --no-2fa Don't enable 2FA on new packages (not recommended) + + Examples + $ np + $ np patch + $ np 1.0.2 + $ np 1.0.2-beta.3 --tag=beta + $ np 1.0.2-beta.3 --tag=beta --contents=dist +`, { + booleanDefault: undefined, + flags: { + anyBranch: { + type: 'boolean' + }, + branch: { + type: 'string' + }, + cleanup: { + type: 'boolean' + }, + tests: { + type: 'boolean' + }, + yolo: { + type: 'boolean' + }, + publish: { + type: 'boolean' + }, + releaseDraft: { + type: 'boolean' + }, + tag: { + type: 'string' + }, + yarn: { + type: 'boolean' + }, + contents: { + type: 'string' + }, + preview: { + type: 'boolean' + }, + testScript: { + type: 'string' + }, + '2fa': { + type: 'boolean' + } + } +}); + +updateNotifier({pkg: cli.pkg}).notify(); + +(async () => { + const pkg = util.readPkg(); + + const defaultFlags = { + cleanup: true, + tests: true, + publish: true, + releaseDraft: true, + yarn: hasYarn(), + '2fa': true + }; + + const localConfig = await config(); + + const flags = { + ...defaultFlags, + ...localConfig, + ...cli.flags + }; + + // Workaround for unintended auto-casing behavior from `meow`. + if ('2Fa' in flags) { + flags['2fa'] = flags['2Fa']; + } + + const runPublish = flags.publish && !pkg.private; + + const availability = flags.publish ? await isPackageNameAvailable(pkg) : { + isAvailable: false, + isUnknown: false + }; + + const version = cli.input.length > 0 ? cli.input[0] : false; + + const options = await ui({ + ...flags, + availability, + version, + runPublish + }, pkg); + + if (!options.confirm) { + process.exit(0); + } + + console.log(); // Prints a newline for readability + const newPkg = await np(options.version, options); + + if (options.preview) { + return; + } + + console.log(`\n ${newPkg.name} ${newPkg.version} published πŸŽ‰`); +})().catch(error => { + console.error(`\n${logSymbols.error} ${error.message}`); + process.exit(1); +}); diff --git a/source/cli.js b/source/cli.js index eb9e0190..3bab28fc 100755 --- a/source/cli.js +++ b/source/cli.js @@ -1,147 +1,17 @@ #!/usr/bin/env node 'use strict'; -// eslint-disable-next-line import/no-unassigned-import -require('symbol-observable'); // Important: This needs to be first to prevent weird Observable incompatibilities -const logSymbols = require('log-symbols'); -const meow = require('meow'); -const updateNotifier = require('update-notifier'); -const hasYarn = require('has-yarn'); -const config = require('./config'); -const {isPackageNameAvailable} = require('./npm/util'); -const version = require('./version'); -const util = require('./util'); -const ui = require('./ui'); -const np = require('.'); +const util = require('util'); +const importLocal = require('import-local'); +const isInstalledGlobally = require('is-installed-globally'); -const cli = meow(` - Usage - $ np +const debuglog = util.debuglog('np'); - Version can be: - ${version.SEMVER_INCREMENTS.join(' | ')} | 1.2.3 - - Options - --any-branch Allow publishing from any branch - --branch Name of the release branch (default: master) - --no-cleanup Skips cleanup of node_modules - --no-tests Skips tests - --yolo Skips cleanup and testing - --no-publish Skips publishing - --preview Show tasks without actually executing them - --tag Publish under a given dist-tag - --no-yarn Don't use Yarn - --contents Subdirectory to publish - --no-release-draft Skips opening a GitHub release draft - --test-script Name of npm run script to run tests before publishing (default: test) - --no-2fa Don't enable 2FA on new packages (not recommended) - - Examples - $ np - $ np patch - $ np 1.0.2 - $ np 1.0.2-beta.3 --tag=beta - $ np 1.0.2-beta.3 --tag=beta --contents=dist -`, { - booleanDefault: undefined, - flags: { - anyBranch: { - type: 'boolean' - }, - branch: { - type: 'string' - }, - cleanup: { - type: 'boolean' - }, - tests: { - type: 'boolean' - }, - yolo: { - type: 'boolean' - }, - publish: { - type: 'boolean' - }, - releaseDraft: { - type: 'boolean' - }, - tag: { - type: 'string' - }, - yarn: { - type: 'boolean' - }, - contents: { - type: 'string' - }, - preview: { - type: 'boolean' - }, - testScript: { - type: 'string' - }, - '2fa': { - type: 'boolean' - } - } -}); - -updateNotifier({pkg: cli.pkg}).notify(); - -(async () => { - const pkg = util.readPkg(); - - const defaultFlags = { - cleanup: true, - tests: true, - publish: true, - releaseDraft: true, - yarn: hasYarn(), - '2fa': true - }; - - const localConfig = await config(); - - const flags = { - ...defaultFlags, - ...localConfig, - ...cli.flags - }; - - // Workaround for unintended auto-casing behavior from `meow`. - if ('2Fa' in flags) { - flags['2fa'] = flags['2Fa']; - } - - const runPublish = flags.publish && !pkg.private; - - const availability = flags.publish ? await isPackageNameAvailable(pkg) : { - isAvailable: false, - isUnknown: false - }; - - const version = cli.input.length > 0 ? cli.input[0] : false; - - const options = await ui({ - ...flags, - availability, - version, - runPublish - }, pkg); - - if (!options.confirm) { - process.exit(0); - } - - console.log(); // Prints a newline for readability - const newPkg = await np(options.version, options); - - if (options.preview) { - return; +// Prefer the local installation +if (!importLocal(__filename)) { + if (isInstalledGlobally) { + debuglog('Using global install of np.'); } - console.log(`\n ${newPkg.name} ${newPkg.version} published πŸŽ‰`); -})().catch(error => { - console.error(`\n${logSymbols.error} ${error.message}`); - process.exit(1); -}); + // eslint-disable-next-line import/no-unassigned-import + require('./cli-implementation'); +} From 08a2c060ed6ebba6729735e20ebe789b64a3288d Mon Sep 17 00:00:00 2001 From: Bunyanuch Saengnet <53788417+bunysae@users.noreply.github.com> Date: Thu, 29 Oct 2020 14:49:46 +0000 Subject: [PATCH 002/180] Show files added since the last release and not part of the package (#456) Co-authored-by: Sindre Sorhus --- .gitmodules | 3 + integration-test | 1 + package.json | 5 +- readme.md | 4 + source/git-util.js | 16 +++ source/npm/util.js | 104 +++++++++++++++++- source/ui.js | 24 ++++ source/util.js | 7 ++ test/fixtures/npmignore/.hg | 1 + test/fixtures/npmignore/.npmignore | 2 + test/fixtures/npmignore/README.txt | 1 + test/fixtures/npmignore/readme.md | 1 + test/fixtures/npmignore/source/ignore.txt | 1 + .../npmignore/source/pay_attention.txt | 1 + test/fixtures/npmignore/test/file.txt | 1 + test/fixtures/package/.hg | 1 + test/fixtures/package/package.json | 3 + test/fixtures/package/source/ignore.txt | 1 + .../fixtures/package/source/pay_attention.txt | 1 + test/fixtures/readme.md | 2 + test/integration.js | 11 ++ test/npmignore.js | 94 ++++++++++++++++ 22 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 .gitmodules create mode 160000 integration-test create mode 100644 test/fixtures/npmignore/.hg create mode 100644 test/fixtures/npmignore/.npmignore create mode 100644 test/fixtures/npmignore/README.txt create mode 100644 test/fixtures/npmignore/readme.md create mode 100644 test/fixtures/npmignore/source/ignore.txt create mode 100644 test/fixtures/npmignore/source/pay_attention.txt create mode 100644 test/fixtures/npmignore/test/file.txt create mode 100644 test/fixtures/package/.hg create mode 100644 test/fixtures/package/package.json create mode 100644 test/fixtures/package/source/ignore.txt create mode 100644 test/fixtures/package/source/pay_attention.txt create mode 100644 test/fixtures/readme.md create mode 100644 test/integration.js create mode 100644 test/npmignore.js diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..18f98e71 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "integration-test"] + path = integration-test + url = https://github.com/bunysae/np_integration_test diff --git a/integration-test b/integration-test new file mode 160000 index 00000000..ad5e6e37 --- /dev/null +++ b/integration-test @@ -0,0 +1 @@ +Subproject commit ad5e6e3776cb3e2b396e7d3f5a6a7a4b5fa0b83e diff --git a/package.json b/package.json index 56fd5fbe..1ee9d715 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "github-url-from-git": "^1.5.0", "has-yarn": "^2.1.0", "hosted-git-info": "^3.0.0", + "ignore-walk": "^3.0.3", "import-local": "^3.0.2", "inquirer": "^7.0.0", "is-installed-globally": "^0.3.1", @@ -51,6 +52,7 @@ "listr-input": "^0.2.1", "log-symbols": "^3.0.0", "meow": "^6.0.0", + "minimatch": "^3.0.4", "new-github-release-url": "^1.0.0", "npm-name": "^6.0.0", "onetime": "^5.1.0", @@ -77,7 +79,8 @@ }, "ava": { "files": [ - "!test/fixtures" + "!test/fixtures", + "!integration-test" ] } } diff --git a/readme.md b/readme.md index 73e723b9..0984eb1c 100644 --- a/readme.md +++ b/readme.md @@ -280,6 +280,10 @@ Host * If you're running into other issues when using SSH, please consult [GitHub's support article](https://help.github.com/articles/connecting-to-github-with-ssh/). +### Ignore strategy + +The [ignore strategy](https://docs.npmjs.com/files/package.json#files), either maintained in the `files`-property in `package.json` or in `.npmignore`, is meant to help reduce the package size. To avoid broken packages caused by essential files being accidentally ignored, `np` prints out all the new and unpublished files added to Git. Test files and other [common files](https://docs.npmjs.com/files/package.json#files) that are never published are not considered. `np` assumes either a standard directory layout or a customized layout represented in the `directories` property in `package.json`. + ## FAQ ### I get an error when publishing my package through Yarn diff --git a/source/git-util.js b/source/git-util.js index b885fd7a..9ebfbf5f 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -1,6 +1,8 @@ 'use strict'; const execa = require('execa'); const escapeStringRegexp = require('escape-string-regexp'); +const ignoreWalker = require('ignore-walk'); +const pkgDir = require('pkg-dir'); const {verifyRequirementSatisfied} = require('./version'); exports.latestTag = async () => { @@ -8,6 +10,20 @@ exports.latestTag = async () => { return stdout; }; +exports.newFilesSinceLastRelease = async () => { + try { + const {stdout} = await execa('git', ['diff', '--stat', '--diff-filter=A', await this.latestTag(), 'HEAD']); + const result = stdout.trim().split('\n').slice(0, -1).map(row => row.slice(0, row.indexOf('|')).trim()); + return result; + } catch (_) { + // Get all files under version control + return ignoreWalker({ + path: pkgDir.sync(), + ignoreFiles: ['.gitignore'] + }); + } +}; + const firstCommit = async () => { const {stdout} = await execa('git', ['rev-list', '--max-parents=0', 'HEAD']); return stdout; diff --git a/source/npm/util.js b/source/npm/util.js index f7011b2d..93371291 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -7,6 +7,8 @@ const ow = require('ow'); const npmName = require('npm-name'); const chalk = require('chalk'); const pkgDir = require('pkg-dir'); +const ignoreWalker = require('ignore-walk'); +const minimatch = require('minimatch'); const {verifyRequirementSatisfied} = require('../version'); exports.checkConnection = () => pTimeout( @@ -117,16 +119,110 @@ exports.verifyRecentNpmVersion = async () => { }; exports.checkIgnoreStrategy = ({files}) => { - const rootDir = pkgDir.sync(); - const npmignoreExists = fs.existsSync(path.resolve(rootDir, '.npmignore')); - - if (!files && !npmignoreExists) { + if (!files && !npmignoreExistsInPackageRootDir()) { console.log(` \n${chalk.bold.yellow('Warning:')} No ${chalk.bold.cyan('files')} field specified in ${chalk.bold.magenta('package.json')} nor is a ${chalk.bold.magenta('.npmignore')} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. `); } }; +function npmignoreExistsInPackageRootDir() { + const rootDir = pkgDir.sync(); + return fs.existsSync(path.resolve(rootDir, '.npmignore')); +} + +async function getFilesIgnoredByDotnpmignore(pkg, fileList) { + const whiteList = await ignoreWalker({ + path: pkgDir.sync(), + ignoreFiles: ['.npmignore'] + }); + return fileList.filter(minimatch.filter(getIgnoredFilesGlob(whiteList, pkg.directories), {matchBase: true, dot: true})); +} + +function getFilesNotIncludedInFilesProperty(pkg, fileList) { + const globArrayForFilesAndDirectories = [...pkg.files]; + const rootDir = pkgDir.sync(); + for (const glob of pkg.files) { + try { + if (fs.statSync(path.resolve(rootDir, glob)).isDirectory()) { + globArrayForFilesAndDirectories.push(`${glob}/**/*`); + } + } catch (_) {} + } + + const result = fileList.filter(minimatch.filter(getIgnoredFilesGlob(globArrayForFilesAndDirectories, pkg.directories), {matchBase: true, dot: true})); + return result.filter(minimatch.filter(getDefaultIncludedFilesGlob(pkg.main), {nocase: true, matchBase: true})); +} + +function getDefaultIncludedFilesGlob(mainFile) { + // According to https://docs.npmjs.com/files/package.json#files + // npm's default behavior is to always include these files. + const filesAlwaysIncluded = [ + 'package.json', + 'README*', + 'CHANGES*', + 'CHANGELOG*', + 'HISTORY*', + 'LICENSE*', + 'LICENCE*', + 'NOTICE*' + ]; + if (mainFile) { + filesAlwaysIncluded.push(mainFile); + } + + return `!{${filesAlwaysIncluded}}`; +} + +function getIgnoredFilesGlob(globArrayFromFilesProperty, packageDirectories) { + // According to https://docs.npmjs.com/files/package.json#files + // npm's default behavior is to ignore these files. + const filesIgnoredByDefault = [ + '.*.swp', + '.npmignore', + '.gitignore', + '._*', + '.DS_Store', + '.hg', + '.npmrc', + '.lock-wscript', + '.svn', + '.wafpickle-N', + '*.orig', + 'config.gypi', + 'CVS', + 'node_modules/**/*', + 'npm-debug.log', + 'package-lock.json', + '.git/**/*', + '.git' + ]; + + // Test files are assumed not to be part of the package + let testDirectoriesGlob = ''; + if (packageDirectories && Array.isArray(packageDirectories.test)) { + testDirectoriesGlob = packageDirectories.test.join(','); + } else if (packageDirectories && typeof packageDirectories.test === 'string') { + testDirectoriesGlob = packageDirectories.test; + } else { + // Fallback to `test` directory + testDirectoriesGlob = 'test/**/*'; + } + + return `!{${globArrayFromFilesProperty.join(',')},${filesIgnoredByDefault.join(',')},${testDirectoriesGlob}}`; +} + +// Get all files which will be ignored by either `.npmignore` or the `files` property in `package.json` (if defined). +exports.getNewAndUnpublishedFiles = async (pkg, newFiles = []) => { + if (pkg.files) { + return getFilesNotIncludedInFilesProperty(pkg, newFiles); + } + + if (npmignoreExistsInPackageRootDir()) { + return getFilesIgnoredByDotnpmignore(pkg, newFiles); + } +}; + exports.getRegistryUrl = async (pkgManager, pkg) => { const args = ['config', 'get', 'registry']; if (exports.isExternalRegistry(pkg)) { diff --git a/source/ui.js b/source/ui.js index 6c82aa27..569be234 100644 --- a/source/ui.js +++ b/source/ui.js @@ -50,6 +50,22 @@ const printCommitLog = async (repoUrl, registryUrl) => { }; }; +const checkIgnoredFiles = async pkg => { + const ignoredFiles = await util.getNewAndUnpublishedFiles(pkg); + if (!ignoredFiles || ignoredFiles.length === 0) { + return true; + } + + const answers = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: `The following new files are not already part of your published package:\n${chalk.reset(ignoredFiles.map(path => `- ${path}`).join('\n'))}\nContinue?`, + default: false + }]); + + return answers.confirm; +}; + module.exports = async (options, pkg) => { const oldVersion = pkg.version; const extraBaseUrls = ['gitlab.com']; @@ -59,6 +75,14 @@ module.exports = async (options, pkg) => { if (options.runPublish) { checkIgnoreStrategy(pkg); + + const answerIgnoredFiles = await checkIgnoredFiles(pkg); + if (!answerIgnoredFiles) { + return { + ...options, + confirm: answerIgnoredFiles + }; + } } console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); diff --git a/source/util.js b/source/util.js index c1762ea3..12a9c988 100644 --- a/source/util.js +++ b/source/util.js @@ -6,6 +6,8 @@ const execa = require('execa'); const pMemoize = require('p-memoize'); const ow = require('ow'); const pkgDir = require('pkg-dir'); +const gitUtil = require('./git-util'); +const npmUtil = require('./npm/util'); exports.readPkg = packagePath => { packagePath = packagePath ? pkgDir.sync(packagePath) : pkgDir.sync(); @@ -69,6 +71,11 @@ exports.getTagVersionPrefix = pMemoize(async options => { } }); +exports.getNewAndUnpublishedFiles = async pkg => { + const listNewFiles = await gitUtil.newFilesSinceLastRelease(); + return npmUtil.getNewAndUnpublishedFiles(pkg, listNewFiles); +}; + exports.getPreReleasePrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); diff --git a/test/fixtures/npmignore/.hg b/test/fixtures/npmignore/.hg new file mode 100644 index 00000000..3f06d3e2 --- /dev/null +++ b/test/fixtures/npmignore/.hg @@ -0,0 +1 @@ +should be ignored by default diff --git a/test/fixtures/npmignore/.npmignore b/test/fixtures/npmignore/.npmignore new file mode 100644 index 00000000..501c21cd --- /dev/null +++ b/test/fixtures/npmignore/.npmignore @@ -0,0 +1,2 @@ +ignore.txt +test diff --git a/test/fixtures/npmignore/README.txt b/test/fixtures/npmignore/README.txt new file mode 100644 index 00000000..5086e7b4 --- /dev/null +++ b/test/fixtures/npmignore/README.txt @@ -0,0 +1 @@ +File is always included in package. diff --git a/test/fixtures/npmignore/readme.md b/test/fixtures/npmignore/readme.md new file mode 100644 index 00000000..5086e7b4 --- /dev/null +++ b/test/fixtures/npmignore/readme.md @@ -0,0 +1 @@ +File is always included in package. diff --git a/test/fixtures/npmignore/source/ignore.txt b/test/fixtures/npmignore/source/ignore.txt new file mode 100644 index 00000000..26ef7633 --- /dev/null +++ b/test/fixtures/npmignore/source/ignore.txt @@ -0,0 +1 @@ +Ignore this file diff --git a/test/fixtures/npmignore/source/pay_attention.txt b/test/fixtures/npmignore/source/pay_attention.txt new file mode 100644 index 00000000..01a573f9 --- /dev/null +++ b/test/fixtures/npmignore/source/pay_attention.txt @@ -0,0 +1 @@ +File is excluded from .npmignore diff --git a/test/fixtures/npmignore/test/file.txt b/test/fixtures/npmignore/test/file.txt new file mode 100644 index 00000000..375fb8ee --- /dev/null +++ b/test/fixtures/npmignore/test/file.txt @@ -0,0 +1 @@ +ignore this file diff --git a/test/fixtures/package/.hg b/test/fixtures/package/.hg new file mode 100644 index 00000000..3f06d3e2 --- /dev/null +++ b/test/fixtures/package/.hg @@ -0,0 +1 @@ +should be ignored by default diff --git a/test/fixtures/package/package.json b/test/fixtures/package/package.json new file mode 100644 index 00000000..b2861deb --- /dev/null +++ b/test/fixtures/package/package.json @@ -0,0 +1,3 @@ +{ + "files": ["pay_attention.txt"] +} diff --git a/test/fixtures/package/source/ignore.txt b/test/fixtures/package/source/ignore.txt new file mode 100644 index 00000000..40f91d34 --- /dev/null +++ b/test/fixtures/package/source/ignore.txt @@ -0,0 +1 @@ +File is excluded from package.json diff --git a/test/fixtures/package/source/pay_attention.txt b/test/fixtures/package/source/pay_attention.txt new file mode 100644 index 00000000..e5f3e01f --- /dev/null +++ b/test/fixtures/package/source/pay_attention.txt @@ -0,0 +1 @@ +File in included in package.json diff --git a/test/fixtures/readme.md b/test/fixtures/readme.md new file mode 100644 index 00000000..c0816aa2 --- /dev/null +++ b/test/fixtures/readme.md @@ -0,0 +1,2 @@ +The directory is for the resources +in the script npmignore.js diff --git a/test/integration.js b/test/integration.js new file mode 100644 index 00000000..c486a9a3 --- /dev/null +++ b/test/integration.js @@ -0,0 +1,11 @@ +const test = require('ava'); +const execa = require('execa'); + +test.after.always(async () => { + await execa('git', ['submodule', 'update', '--remote']); +}); + +test('Integration tests', async t => { + await execa('ava', {cwd: 'integration-test'}); + t.pass(); +}); diff --git a/test/npmignore.js b/test/npmignore.js new file mode 100644 index 00000000..2769916a --- /dev/null +++ b/test/npmignore.js @@ -0,0 +1,94 @@ +import path from 'path'; +import test from 'ava'; +import proxyquire from 'proxyquire'; + +const newFiles = [ + 'source/ignore.txt', + 'source/pay_attention.txt', + '.hg', + 'test/file.txt', + 'readme.md', + 'README.txt' +]; + +test('ignored files using file-attribute in package.json with one file', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'package') + } + }); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['pay_attention.txt']}, newFiles), ['source/ignore.txt']); +}); + +test('ignored file using file-attribute in package.json with directory', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'package') + } + }); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source']}, newFiles), []); +}); + +test('ignored test files using files attribute and directory structure in package.json', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'package') + } + }); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source'], directories: {test: 'test-tap'}}, newFiles), ['test/file.txt']); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source'], directories: {test: ['test-tap']}}, newFiles), ['test/file.txt']); +}); + +test('ignored files using .npmignore', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'npmignore') + } + }); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({name: 'npmignore'}, newFiles), ['source/ignore.txt']); +}); + +test('ignored test files using files attribute and .npmignore', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'npmignore') + } + }); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({directories: {test: 'test-tap'}}, newFiles), ['source/ignore.txt', 'test/file.txt']); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({directories: {test: ['test-tap']}}, newFiles), ['source/ignore.txt', 'test/file.txt']); +}); + +test('dot files using files attribute', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'package') + } + }); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source']}, ['test/.dotfile']), []); +}); + +test('dot files using .npmignore', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'npmignore') + } + }); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({}, ['test/.dot']), []); +}); + +test('ignore strategy is not used', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures') + } + }); + t.is(await testedModule.getNewAndUnpublishedFiles({name: 'no ignore strategy'}, newFiles), undefined); +}); From c43c6cff059f327f56048ef17b767fe11af2b83e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 29 Oct 2020 15:56:10 +0100 Subject: [PATCH 003/180] Upgrade dependencies --- package.json | 60 +++++++++++++++++++++++----------------------- source/cli.js | 6 ++--- source/git-util.js | 8 +++---- source/index.js | 2 +- source/npm/util.js | 4 ++-- source/util.js | 4 ++-- test/hyperlinks.js | 8 +++---- 7 files changed, 46 insertions(+), 46 deletions(-) diff --git a/package.json b/package.json index 1ee9d715..f51f5cb6 100644 --- a/package.json +++ b/package.json @@ -30,52 +30,52 @@ "commit" ], "dependencies": { - "@samverschueren/stream-to-observable": "^0.3.0", - "any-observable": "^0.5.0", + "@samverschueren/stream-to-observable": "^0.3.1", + "any-observable": "^0.5.1", "async-exit-hook": "^2.0.1", - "chalk": "^3.0.0", - "cosmiconfig": "^6.0.0", - "del": "^4.1.0", + "chalk": "^4.1.0", + "cosmiconfig": "^7.0.0", + "del": "^6.0.0", "escape-goat": "^3.0.0", - "escape-string-regexp": "^2.0.0", - "execa": "^4.0.0", + "escape-string-regexp": "^4.0.0", + "execa": "^4.1.0", "github-url-from-git": "^1.5.0", "has-yarn": "^2.1.0", - "hosted-git-info": "^3.0.0", + "hosted-git-info": "^3.0.7", "ignore-walk": "^3.0.3", "import-local": "^3.0.2", - "inquirer": "^7.0.0", - "is-installed-globally": "^0.3.1", + "inquirer": "^7.3.3", + "is-installed-globally": "^0.3.2", "is-scoped": "^2.1.0", "issue-regex": "^3.1.0", "listr": "^0.14.3", "listr-input": "^0.2.1", - "log-symbols": "^3.0.0", - "meow": "^6.0.0", + "log-symbols": "^4.0.0", + "meow": "^8.0.0", "minimatch": "^3.0.4", "new-github-release-url": "^1.0.0", - "npm-name": "^6.0.0", - "onetime": "^5.1.0", - "open": "^7.0.0", - "ow": "^0.15.0", - "p-memoize": "^3.1.0", - "p-timeout": "^3.1.0", - "pkg-dir": "^4.1.0", - "read-pkg-up": "^7.0.0", - "rxjs": "^6.5.4", - "semver": "^7.1.1", - "split": "^1.0.0", - "symbol-observable": "^1.2.0", - "terminal-link": "^2.0.0", - "update-notifier": "^4.0.0" + "npm-name": "^6.0.1", + "onetime": "^5.1.2", + "open": "^7.3.0", + "ow": "^0.18.0", + "p-memoize": "^4.0.1", + "p-timeout": "^3.2.0", + "pkg-dir": "^5.0.0", + "read-pkg-up": "^7.0.1", + "rxjs": "^6.6.3", + "semver": "^7.3.2", + "split": "^1.0.1", + "symbol-observable": "^2.0.3", + "terminal-link": "^2.1.1", + "update-notifier": "^5.0.0" }, "devDependencies": { "ava": "^2.3.0", - "execa_test_double": "^4.0.0", + "execa_test_double": "^4.0.1", "mockery": "^2.1.0", - "proxyquire": "^2.1.0", - "sinon": "^8.0.1", - "xo": "^0.25.3" + "proxyquire": "^2.1.3", + "sinon": "^9.2.1", + "xo": "^0.34.1" }, "ava": { "files": [ diff --git a/source/cli.js b/source/cli.js index 3bab28fc..f79dcc86 100755 --- a/source/cli.js +++ b/source/cli.js @@ -1,15 +1,15 @@ #!/usr/bin/env node 'use strict'; -const util = require('util'); +const {debuglog} = require('util'); const importLocal = require('import-local'); const isInstalledGlobally = require('is-installed-globally'); -const debuglog = util.debuglog('np'); +const log = debuglog('np'); // Prefer the local installation if (!importLocal(__filename)) { if (isInstalledGlobally) { - debuglog('Using global install of np.'); + log('Using global install of np.'); } // eslint-disable-next-line import/no-unassigned-import diff --git a/source/git-util.js b/source/git-util.js index 9ebfbf5f..0ed63395 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -15,7 +15,7 @@ exports.newFilesSinceLastRelease = async () => { const {stdout} = await execa('git', ['diff', '--stat', '--diff-filter=A', await this.latestTag(), 'HEAD']); const result = stdout.trim().split('\n').slice(0, -1).map(row => row.slice(0, row.indexOf('|')).trim()); return result; - } catch (_) { + } catch { // Get all files under version control return ignoreWalker({ path: pkgDir.sync(), @@ -34,7 +34,7 @@ exports.latestTagOrFirstCommit = async () => { try { // In case a previous tag exists, we use it to compare the current repo status to. latest = await exports.latestTag(); - } catch (_) { + } catch { // Otherwise, we fallback to using the first commit for comparison. latest = await firstCommit(); } @@ -70,7 +70,7 @@ exports.isWorkingTreeClean = async () => { } return true; - } catch (_) { + } catch { return false; } }; @@ -86,7 +86,7 @@ exports.isRemoteHistoryClean = async () => { try { // Gracefully handle no remote set up. const {stdout} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); history = stdout; - } catch (_) {} + } catch {} if (history && history !== '0') { return false; diff --git a/source/index.js b/source/index.js index f0209fbb..bc629844 100644 --- a/source/index.js +++ b/source/index.js @@ -1,5 +1,5 @@ 'use strict'; -require('any-observable/register/rxjs-all'); // eslint-disable-line import/no-unassigned-import +require('any-observable/register/rxjs-all'); const fs = require('fs'); const path = require('path'); const execa = require('execa'); diff --git a/source/npm/util.js b/source/npm/util.js index 93371291..fb92552b 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -16,7 +16,7 @@ exports.checkConnection = () => pTimeout( try { await execa('npm', ['ping']); return true; - } catch (_) { + } catch { throw new Error('Connection to npm registry failed'); } })(), @@ -147,7 +147,7 @@ function getFilesNotIncludedInFilesProperty(pkg, fileList) { if (fs.statSync(path.resolve(rootDir, glob)).isDirectory()) { globArrayForFilesAndDirectories.push(`${glob}/**/*`); } - } catch (_) {} + } catch {} } const result = fileList.filter(minimatch.filter(getIgnoredFilesGlob(globArrayForFilesAndDirectories, pkg.directories), {matchBase: true, dot: true})); diff --git a/source/util.js b/source/util.js index 12a9c988..040e3682 100644 --- a/source/util.js +++ b/source/util.js @@ -66,7 +66,7 @@ exports.getTagVersionPrefix = pMemoize(async options => { const {stdout} = await execa('npm', ['config', 'get', 'tag-version-prefix']); return stdout; - } catch (_) { + } catch { return 'v'; } }); @@ -95,7 +95,7 @@ exports.getPreReleasePrefix = pMemoize(async options => { } return ''; - } catch (_) { + } catch { return ''; } }); diff --git a/test/hyperlinks.js b/test/hyperlinks.js index 98b0632d..963a9663 100644 --- a/test/hyperlinks.js +++ b/test/hyperlinks.js @@ -23,14 +23,14 @@ test('linkifyIssues correctly links issues', t => { }); test('linkifyIssues returns raw message if url is not provided', t => { - const msg = 'Commit message - fixes #5'; - t.is(linkifyIssues(undefined, msg), msg); + const message = 'Commit message - fixes #5'; + t.is(linkifyIssues(undefined, message), message); }); test.serial('linkifyIssues returns raw message if terminalLink is not supported', t => { mockTerminalLinkUnsupported(); - const msg = 'Commit message - fixes #6'; - t.is(linkifyIssues(MOCK_REPO_URL, msg), msg); + const message = 'Commit message - fixes #6'; + t.is(linkifyIssues(MOCK_REPO_URL, message), message); }); test('linkifyCommit correctly links commits', t => { From fee5ab8cf8c7c1afe6e96fa68021cf7611ce52ed Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 29 Oct 2020 16:51:14 +0100 Subject: [PATCH 004/180] 7.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f51f5cb6..039dc2d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "6.5.0", + "version": "7.0.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 6b410d7bee4e5f63eb1d325a2f193ffabe3f4b3f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 25 Nov 2020 14:25:30 +0700 Subject: [PATCH 005/180] Move to GitHub Actions --- .github/workflows/main.yml | 22 ++++++++++++++++++++++ .travis.yml | 6 ------ readme.md | 2 +- 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/main.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..c1870cf7 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,22 @@ +name: CI +on: + - push + - pull_request +jobs: + test: + name: Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - 14 + - 12 + - 10 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index bec5fdd6..00000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: node_js -node_js: - - '12' - - '10' -before_script: - - npm install --global npm@6.8.0 diff --git a/readme.md b/readme.md index 0984eb1c..034b4521 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# np [![Build Status](https://travis-ci.com/sindresorhus/np.svg?branch=master)](https://travis-ci.com/github/sindresorhus/np) [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) +# np [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) > A better `npm publish` From 98172611d24af1147468f1ad86c9e425f9e40c52 Mon Sep 17 00:00:00 2001 From: Bunyanuch Saengnet <53788417+bunysae@users.noreply.github.com> Date: Sat, 19 Dec 2020 16:40:25 +0100 Subject: [PATCH 006/180] Fix integration tests on GitHub Actions (#584) Co-authored-by: Sindre Sorhus --- .github/workflows/main.yml | 4 ++++ integration-test | 2 +- test/integration.js | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c1870cf7..96917392 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,8 +15,12 @@ jobs: - 10 steps: - uses: actions/checkout@v2 + with: + submodules: true - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - run: git config --global user.name "Github Actions" + - run: git config --global user.email "actions@users.noreply.github.com" - run: npm install - run: npm test diff --git a/integration-test b/integration-test index ad5e6e37..1771d17d 160000 --- a/integration-test +++ b/integration-test @@ -1 +1 @@ -Subproject commit ad5e6e3776cb3e2b396e7d3f5a6a7a4b5fa0b83e +Subproject commit 1771d17d524bb33a4cfc99df36000a4e460fcde5 diff --git a/test/integration.js b/test/integration.js index c486a9a3..a26daab7 100644 --- a/test/integration.js +++ b/test/integration.js @@ -6,6 +6,6 @@ test.after.always(async () => { }); test('Integration tests', async t => { - await execa('ava', {cwd: 'integration-test'}); + await execa('npx', ['ava'], {cwd: 'integration-test'}); t.pass(); }); From 4ae26d93f47ba0f77e78c273d1e7bcf935029a9c Mon Sep 17 00:00:00 2001 From: Bunyanuch Saengnet <53788417+bunysae@users.noreply.github.com> Date: Mon, 21 Dec 2020 06:22:26 +0100 Subject: [PATCH 007/180] Show prompt only in interactive mode (#583) Co-authored-by: Sindre Sorhus --- package.json | 1 + source/ui.js | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 039dc2d8..97528a36 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "import-local": "^3.0.2", "inquirer": "^7.3.3", "is-installed-globally": "^0.3.2", + "is-interactive": "^1.0.0", "is-scoped": "^2.1.0", "issue-regex": "^3.1.0", "listr": "^0.14.3", diff --git a/source/ui.js b/source/ui.js index 569be234..7c5f6ad8 100644 --- a/source/ui.js +++ b/source/ui.js @@ -4,6 +4,7 @@ const chalk = require('chalk'); const githubUrlFromGit = require('github-url-from-git'); const {htmlEscape} = require('escape-goat'); const isScoped = require('is-scoped'); +const isInteractive = require('is-interactive'); const util = require('./util'); const git = require('./git-util'); const {prereleaseTags, checkIgnoreStrategy, getRegistryUrl, isExternalRegistry} = require('./npm/util'); @@ -56,10 +57,16 @@ const checkIgnoredFiles = async pkg => { return true; } + const message = `The following new files are not already part of your published package:\n${chalk.reset(ignoredFiles.map(path => `- ${path}`).join('\n'))}`; + if (!isInteractive()) { + console.log(message); + return true; + } + const answers = await inquirer.prompt([{ type: 'confirm', name: 'confirm', - message: `The following new files are not already part of your published package:\n${chalk.reset(ignoredFiles.map(path => `- ${path}`).join('\n'))}\nContinue?`, + message: `${message}\nContinue?`, default: false }]); From 3466903c8940d5bcbf12e17ac27e65f66e990d3b Mon Sep 17 00:00:00 2001 From: Bunyanuch Saengnet <53788417+bunysae@users.noreply.github.com> Date: Mon, 21 Dec 2020 06:23:16 +0100 Subject: [PATCH 008/180] Handle long path names in the "show files added since last release" feature (#579) Co-authored-by: Sindre Sorhus --- source/git-util.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index 0ed63395..e3bd672d 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -12,8 +12,8 @@ exports.latestTag = async () => { exports.newFilesSinceLastRelease = async () => { try { - const {stdout} = await execa('git', ['diff', '--stat', '--diff-filter=A', await this.latestTag(), 'HEAD']); - const result = stdout.trim().split('\n').slice(0, -1).map(row => row.slice(0, row.indexOf('|')).trim()); + const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await this.latestTag(), 'HEAD']); + const result = stdout.trim().split('\n').map(row => row.trim()); return result; } catch { // Get all files under version control From 316bed29a6ae4939cefd0d4f6d33c6959ba2894f Mon Sep 17 00:00:00 2001 From: Corentin Girard Date: Sat, 26 Dec 2020 14:35:01 +0100 Subject: [PATCH 009/180] Add `--release-draft-only` flag (#578) --- readme.md | 27 ++++++++-------- source/cli-implementation.js | 37 ++++++++++++---------- source/git-util.js | 15 +++++++++ source/index.js | 5 +++ source/ui.js | 60 +++++++++++++++++++++++++++++++----- 5 files changed, 107 insertions(+), 37 deletions(-) diff --git a/readme.md b/readme.md index 034b4521..0fd86cb6 100644 --- a/readme.md +++ b/readme.md @@ -50,19 +50,20 @@ $ np --help patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3 Options - --any-branch Allow publishing from any branch - --branch Name of the release branch (default: master) - --no-cleanup Skips cleanup of node_modules - --no-tests Skips tests - --yolo Skips cleanup and testing - --no-publish Skips publishing - --preview Show tasks without actually executing them - --tag Publish under a given dist-tag - --no-yarn Don't use Yarn - --contents Subdirectory to publish - --no-release-draft Skips opening a GitHub release draft - --test-script Name of npm run script to run tests before publishing (default: test) - --no-2fa Don't enable 2FA on new packages (not recommended) + --any-branch Allow publishing from any branch + --branch Name of the release branch (default: master) + --no-cleanup Skips cleanup of node_modules + --no-tests Skips tests + --yolo Skips cleanup and testing + --no-publish Skips publishing + --preview Show tasks without actually executing them + --tag Publish under a given dist-tag + --no-yarn Don't use Yarn + --contents Subdirectory to publish + --no-release-draft Skips opening a GitHub release draft + --release-draft-only Only opens a GitHub release draft + --test-script Name of npm run script to run tests before publishing (default: test) + --no-2fa Don't enable 2FA on new packages (not recommended) Examples $ np diff --git a/source/cli-implementation.js b/source/cli-implementation.js index eb9e0190..f5ded4b1 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -21,19 +21,20 @@ const cli = meow(` ${version.SEMVER_INCREMENTS.join(' | ')} | 1.2.3 Options - --any-branch Allow publishing from any branch - --branch Name of the release branch (default: master) - --no-cleanup Skips cleanup of node_modules - --no-tests Skips tests - --yolo Skips cleanup and testing - --no-publish Skips publishing - --preview Show tasks without actually executing them - --tag Publish under a given dist-tag - --no-yarn Don't use Yarn - --contents Subdirectory to publish - --no-release-draft Skips opening a GitHub release draft - --test-script Name of npm run script to run tests before publishing (default: test) - --no-2fa Don't enable 2FA on new packages (not recommended) + --any-branch Allow publishing from any branch + --branch Name of the release branch (default: master) + --no-cleanup Skips cleanup of node_modules + --no-tests Skips tests + --yolo Skips cleanup and testing + --no-publish Skips publishing + --preview Show tasks without actually executing them + --tag Publish under a given dist-tag + --no-yarn Don't use Yarn + --contents Subdirectory to publish + --no-release-draft Skips opening a GitHub release draft + --release-draft-only Only opens a GitHub release draft for the latest published version + --test-script Name of npm run script to run tests before publishing (default: test) + --no-2fa Don't enable 2FA on new packages (not recommended) Examples $ np @@ -65,6 +66,9 @@ const cli = meow(` releaseDraft: { type: 'boolean' }, + releaseDraftOnly: { + type: 'boolean' + }, tag: { type: 'string' }, @@ -113,14 +117,15 @@ updateNotifier({pkg: cli.pkg}).notify(); flags['2fa'] = flags['2Fa']; } - const runPublish = flags.publish && !pkg.private; + const runPublish = !flags.releaseDraftOnly && flags.publish && !pkg.private; const availability = flags.publish ? await isPackageNameAvailable(pkg) : { isAvailable: false, isUnknown: false }; - const version = cli.input.length > 0 ? cli.input[0] : false; + // Use current (latest) version when 'releaseDraftOnly', otherwise use the first argument. + const version = flags.releaseDraftOnly ? pkg.version : (cli.input.length > 0 ? cli.input[0] : false); const options = await ui({ ...flags, @@ -136,7 +141,7 @@ updateNotifier({pkg: cli.pkg}).notify(); console.log(); // Prints a newline for readability const newPkg = await np(options.version, options); - if (options.preview) { + if (options.preview || options.releaseDraftOnly) { return; } diff --git a/source/git-util.js b/source/git-util.js index e3bd672d..50d639cc 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -29,6 +29,21 @@ const firstCommit = async () => { return stdout; }; +exports.previousTagOrFirstCommit = async () => { + const {stdout} = await execa('git', ['tag']); + const tags = stdout.split('\n'); + + if (tags.length === 0) { + return; + } + + if (tags.length === 1) { + return firstCommit(); + } + + return tags[tags.length - 2]; +}; + exports.latestTagOrFirstCommit = async () => { let latest; try { diff --git a/source/index.js b/source/index.js index bc629844..5aaca744 100644 --- a/source/index.js +++ b/source/index.js @@ -58,6 +58,11 @@ module.exports = async (input = 'patch', options) => { const testScript = options.testScript || 'test'; const testCommand = options.testScript ? ['run', testScript] : [testScript]; + if (options.releaseDraftOnly) { + await releaseTaskHelper(options, pkg); + return pkg; + } + let publishStatus = 'UNKNOWN'; let pushedObjects; diff --git a/source/ui.js b/source/ui.js index 7c5f6ad8..b9e6333e 100644 --- a/source/ui.js +++ b/source/ui.js @@ -11,18 +11,26 @@ const {prereleaseTags, checkIgnoreStrategy, getRegistryUrl, isExternalRegistry} const version = require('./version'); const prettyVersionDiff = require('./pretty-version-diff'); -const printCommitLog = async (repoUrl, registryUrl) => { - const latest = await git.latestTagOrFirstCommit(); - const log = await git.commitLogFromRevision(latest); +const printCommitLog = async (repoUrl, registryUrl, fromLatestTag) => { + 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: () => {} }; } - const commits = log.split('\n') + let hasUnreleasedCommits = false; + let commitRangeText = `${revision}...master`; + + let commits = log.split('\n') .map(commit => { const splitIndex = commit.lastIndexOf(' '); return { @@ -31,6 +39,20 @@ const printCommitLog = async (repoUrl, registryUrl) => { }; }); + if (!fromLatestTag) { + const latestTag = await git.latestTag(); + const versionBumpCommitName = 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; + } + + // 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); @@ -39,14 +61,15 @@ const printCommitLog = async (repoUrl, registryUrl) => { const releaseNotes = nextTag => commits.map(commit => `- ${htmlEscape(commit.message)} ${commit.id}` - ).join('\n') + `\n\n${repoUrl}/compare/${latest}...${nextTag}`; + ).join('\n') + `\n\n${repoUrl}/compare/${revision}...${nextTag}`; - const commitRange = util.linkifyCommitRange(repoUrl, `${latest}...master`); + 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 }; }; @@ -92,7 +115,11 @@ module.exports = async (options, pkg) => { } } - console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); + if (options.releaseDraftOnly) { + console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); + } else { + console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); + } const prompts = [ { @@ -176,7 +203,24 @@ module.exports = async (options, pkg) => { } ]; - const {hasCommits, releaseNotes} = await printCommitLog(repoUrl, registryUrl); + const useLatestTag = !options.releaseDraftOnly; + const {hasCommits, hasUnreleasedCommits, releaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag); + + if (hasUnreleasedCommits && options.releaseDraftOnly) { + const answers = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: 'Unreleased commits found. They won\'t be included in the release draft. Continue?', + default: false + }]); + + if (!answers.confirm) { + return { + ...options, + ...answers + }; + } + } if (options.version) { return { From 45a7423db93b25cc67fdf9cad7ef27b5b382578c Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 24 Dec 2020 15:42:27 +0700 Subject: [PATCH 010/180] Improve internal naming --- source/npm/util.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index fb92552b..e97e3bed 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -132,11 +132,11 @@ function npmignoreExistsInPackageRootDir() { } async function getFilesIgnoredByDotnpmignore(pkg, fileList) { - const whiteList = await ignoreWalker({ + const allowList = await ignoreWalker({ path: pkgDir.sync(), ignoreFiles: ['.npmignore'] }); - return fileList.filter(minimatch.filter(getIgnoredFilesGlob(whiteList, pkg.directories), {matchBase: true, dot: true})); + return fileList.filter(minimatch.filter(getIgnoredFilesGlob(allowList, pkg.directories), {matchBase: true, dot: true})); } function getFilesNotIncludedInFilesProperty(pkg, fileList) { From bb1464132b22f570f0c36758cb6060dc13489497 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 26 Dec 2020 20:42:18 +0700 Subject: [PATCH 011/180] Upgrade dependencies --- package.json | 18 +++++++++--------- source/npm/util.js | 2 +- source/util.js | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 97528a36..5a86be19 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "del": "^6.0.0", "escape-goat": "^3.0.0", "escape-string-regexp": "^4.0.0", - "execa": "^4.1.0", + "execa": "^5.0.0", "github-url-from-git": "^1.5.0", "has-yarn": "^2.1.0", "hosted-git-info": "^3.0.7", @@ -52,31 +52,31 @@ "listr": "^0.14.3", "listr-input": "^0.2.1", "log-symbols": "^4.0.0", - "meow": "^8.0.0", + "meow": "^8.1.0", "minimatch": "^3.0.4", "new-github-release-url": "^1.0.0", "npm-name": "^6.0.1", "onetime": "^5.1.2", "open": "^7.3.0", - "ow": "^0.18.0", + "ow": "^0.21.0", "p-memoize": "^4.0.1", - "p-timeout": "^3.2.0", + "p-timeout": "^4.1.0", "pkg-dir": "^5.0.0", "read-pkg-up": "^7.0.1", "rxjs": "^6.6.3", - "semver": "^7.3.2", + "semver": "^7.3.4", "split": "^1.0.1", - "symbol-observable": "^2.0.3", + "symbol-observable": "^3.0.0", "terminal-link": "^2.1.1", - "update-notifier": "^5.0.0" + "update-notifier": "^5.0.1" }, "devDependencies": { "ava": "^2.3.0", "execa_test_double": "^4.0.1", "mockery": "^2.1.0", "proxyquire": "^2.1.3", - "sinon": "^9.2.1", - "xo": "^0.34.1" + "sinon": "^9.2.2", + "xo": "^0.36.1" }, "ava": { "files": [ diff --git a/source/npm/util.js b/source/npm/util.js index e97e3bed..f4393cb3 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); const execa = require('execa'); const pTimeout = require('p-timeout'); -const ow = require('ow'); +const {default: ow} = require('ow'); const npmName = require('npm-name'); const chalk = require('chalk'); const pkgDir = require('pkg-dir'); diff --git a/source/util.js b/source/util.js index 040e3682..87105c69 100644 --- a/source/util.js +++ b/source/util.js @@ -4,7 +4,7 @@ const issueRegex = require('issue-regex'); const terminalLink = require('terminal-link'); const execa = require('execa'); const pMemoize = require('p-memoize'); -const ow = require('ow'); +const {default: ow} = require('ow'); const pkgDir = require('pkg-dir'); const gitUtil = require('./git-util'); const npmUtil = require('./npm/util'); From 8445136688de80ad4e5b8fc53cf87fb8c1ed5bb8 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 26 Dec 2020 20:50:42 +0700 Subject: [PATCH 012/180] 7.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5a86be19..494a52fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.0.0", + "version": "7.1.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From ca3d830facbb0a9f9b5da3dedbcb800ee1fbafa1 Mon Sep 17 00:00:00 2001 From: Bunyanuch Saengnet <53788417+bunysae@users.noreply.github.com> Date: Sun, 27 Dec 2020 11:00:28 +0000 Subject: [PATCH 013/180] Fix confusing prompt with empty file list (#589) --- source/git-util.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/git-util.js b/source/git-util.js index 50d639cc..ec6a5b11 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -13,6 +13,10 @@ exports.latestTag = async () => { exports.newFilesSinceLastRelease = async () => { try { const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await this.latestTag(), 'HEAD']); + if (stdout.trim().length === 0) { + return []; + } + const result = stdout.trim().split('\n').map(row => row.trim()); return result; } catch { From 8f812e0e40d8a2d6d153d7ceb494822c1119fbc0 Mon Sep 17 00:00:00 2001 From: Jake Fried Date: Thu, 31 Dec 2020 00:59:10 -0500 Subject: [PATCH 014/180] Make commit log relative to release branch (#580) --- source/cli-implementation.js | 5 ++++- source/git-util.js | 32 +++++++++++++++++++++++++++++--- source/ui.js | 8 ++++---- test/git-tasks.js | 12 ++++++------ 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index f5ded4b1..7d244ad4 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -7,6 +7,7 @@ const meow = require('meow'); const updateNotifier = require('update-notifier'); const hasYarn = require('has-yarn'); const config = require('./config'); +const git = require('./git-util'); const {isPackageNameAvailable} = require('./npm/util'); const version = require('./version'); const util = require('./util'); @@ -127,11 +128,13 @@ updateNotifier({pkg: cli.pkg}).notify(); // Use current (latest) version when 'releaseDraftOnly', otherwise use the first argument. const version = flags.releaseDraftOnly ? pkg.version : (cli.input.length > 0 ? cli.input[0] : false); + const branch = flags.branch || await git.defaultBranch(); const options = await ui({ ...flags, availability, version, - runPublish + runPublish, + branch }, pkg); if (!options.confirm) { diff --git a/source/git-util.js b/source/git-util.js index ec6a5b11..d65669ca 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -74,10 +74,9 @@ exports.currentBranch = async () => { }; exports.verifyCurrentBranchIsReleaseBranch = async releaseBranch => { - const allowedBranches = releaseBranch ? [releaseBranch] : ['main', 'master']; const currentBranch = await exports.currentBranch(); - if (!allowedBranches.includes(currentBranch)) { - throw new Error(`Not on ${allowedBranches.map(branch => `\`${branch}\``).join('/')} branch. Use --any-branch to publish anyway, or set a different release branch using --branch.`); + if (currentBranch !== releaseBranch) { + throw new Error(`Not on \`${releaseBranch}\` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.`); } }; @@ -152,6 +151,33 @@ exports.tagExistsOnRemote = async tagName => { } }; +async function hasLocalBranch(branch) { + try { + await execa('git', [ + 'show-ref', + '--verify', + '--quiet', + `refs/heads/${branch}` + ]); + return true; + } catch { + return false; + } +} + +exports.defaultBranch = async () => { + for (const branch of ['main', 'master', 'gh-pages']) { + // eslint-disable-next-line no-await-in-loop + if (await hasLocalBranch(branch)) { + return branch; + } + } + + throw new Error( + 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.' + ); +}; + exports.verifyTagDoesNotExistOnRemote = async tagName => { if (await exports.tagExistsOnRemote(tagName)) { throw new Error(`Git tag \`${tagName}\` already exists.`); diff --git a/source/ui.js b/source/ui.js index b9e6333e..5e1214cc 100644 --- a/source/ui.js +++ b/source/ui.js @@ -11,7 +11,7 @@ const {prereleaseTags, checkIgnoreStrategy, getRegistryUrl, isExternalRegistry} const version = require('./version'); const prettyVersionDiff = require('./pretty-version-diff'); -const printCommitLog = async (repoUrl, registryUrl, fromLatestTag) => { +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.'); @@ -28,7 +28,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag) => { } let hasUnreleasedCommits = false; - let commitRangeText = `${revision}...master`; + let commitRangeText = `${revision}...${releaseBranch}`; let commits = log.split('\n') .map(commit => { @@ -64,7 +64,6 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag) => { ).join('\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 { @@ -102,6 +101,7 @@ module.exports = async (options, pkg) => { const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); const pkgManager = options.yarn ? 'yarn' : 'npm'; const registryUrl = await getRegistryUrl(pkgManager, pkg); + const releaseBranch = options.branch; if (options.runPublish) { checkIgnoreStrategy(pkg); @@ -204,7 +204,7 @@ module.exports = async (options, pkg) => { ]; const useLatestTag = !options.releaseDraftOnly; - const {hasCommits, hasUnreleasedCommits, releaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag); + const {hasCommits, hasUnreleasedCommits, releaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag, releaseBranch); if (hasUnreleasedCommits && options.releaseDraftOnly) { const answers = await inquirer.prompt([{ diff --git a/test/git-tasks.js b/test/git-tasks.js index 3fd73534..56f5422a 100644 --- a/test/git-tasks.js +++ b/test/git-tasks.js @@ -24,7 +24,7 @@ test.beforeEach(() => { execaStub.resetStub(); }); -test.serial('should fail when release branch is not specified, current branch is not main/master and publishing from any branch not permitted', async t => { +test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', async t => { execaStub.createStub([ { command: 'git symbolic-ref --short HEAD', @@ -32,8 +32,8 @@ test.serial('should fail when release branch is not specified, current branch is stdout: 'feature' } ]); - await t.throwsAsync(run(testedModule({})), - {message: 'Not on `main`/`master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}); + await t.throwsAsync(run(testedModule({branch: 'master'})), + {message: 'Not on `master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}); t.true(SilentRenderer.tasks.some(task => task.title === 'Check current branch' && task.hasFailed())); }); @@ -85,7 +85,7 @@ test.serial('should fail when local working tree modified', async t => { stdout: 'M source/git-tasks.js' } ]); - await t.throwsAsync(run(testedModule({})), {message: 'Unclean working tree. Commit or stash changes first.'}); + await t.throwsAsync(run(testedModule({branch: 'master'})), {message: 'Unclean working tree. Commit or stash changes first.'}); t.true(SilentRenderer.tasks.some(task => task.title === 'Check local working tree' && task.hasFailed())); }); @@ -107,7 +107,7 @@ test.serial('should fail when remote history differs', async t => { stdout: '1' } ]); - await t.throwsAsync(run(testedModule({})), {message: 'Remote history differs. Please pull changes.'}); + await t.throwsAsync(run(testedModule({branch: 'master'})), {message: 'Remote history differs. Please pull changes.'}); t.true(SilentRenderer.tasks.some(task => task.title === 'Check remote history' && task.hasFailed())); }); @@ -129,5 +129,5 @@ test.serial('checks should pass when publishing from master, working tree is cle stdout: '' } ]); - await t.notThrowsAsync(run(testedModule({}))); + await t.notThrowsAsync(run(testedModule({branch: 'master'}))); }); From c76987a42cefbe632c9cbce761de81d887d86264 Mon Sep 17 00:00:00 2001 From: Govind S Date: Thu, 31 Dec 2020 11:32:59 +0530 Subject: [PATCH 015/180] Check if yarn.lock is ignored before throwing error (#582) Co-authored-by: Sindre Sorhus --- source/git-util.js | 15 +++++++++++++++ source/index.js | 22 ++++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index d65669ca..92d01f69 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -226,3 +226,18 @@ exports.verifyRecentGitVersion = async () => { verifyRequirementSatisfied('git', installedVersion); }; + +exports.checkIfFileGitIgnored = async pathToFile => { + try { + const {stdout} = await execa('git', ['check-ignore', pathToFile]); + return Boolean(stdout); + } catch (error) { + // If file is not ignored, `git check-ignore` throws an empty error and exits. + // Check that and return false so as not to throw an unwanted error. + if (error.stdout === '' && error.stderr === '') { + return false; + } + + throw error; + } +}; diff --git a/source/index.js b/source/index.js index 5aaca744..73785efe 100644 --- a/source/index.js +++ b/source/index.js @@ -128,15 +128,21 @@ module.exports = async (input = 'patch', options) => { { title: 'Installing dependencies using Yarn', enabled: () => options.yarn === true, - task: () => exec('yarn', ['install', '--frozen-lockfile', '--production=false']).pipe( - catchError(error => { - if (error.stderr.startsWith('error Your lockfile needs to be updated')) { - return throwError(new Error('yarn.lock file is outdated. Run yarn, commit the updated lockfile and try again.')); - } + task: () => { + return exec('yarn', ['install', '--frozen-lockfile', '--production=false']).pipe( + catchError(async error => { + if ((!error.stderr.startsWith('error Your lockfile needs to be updated'))) { + return; + } - return throwError(error); - }) - ) + if (await git.checkIfFileGitIgnored('yarn.lock')) { + return; + } + + throw new Error('yarn.lock file is outdated. Run yarn, commit the updated lockfile and try again.'); + }) + ); + } }, { title: 'Installing dependencies using npm', From be9686dbdbe40811b6d965373a6fc7a2e7760b49 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 31 Dec 2020 14:31:41 +0700 Subject: [PATCH 016/180] 7.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 494a52fb..02eb1199 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.1.0", + "version": "7.2.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 0df5574cff726da313d8a11ccc28125ac2d058b6 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 24 Jan 2021 14:34:36 +0700 Subject: [PATCH 017/180] Fix help output --- readme.md | 2 +- source/cli-implementation.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 0fd86cb6..be065743 100644 --- a/readme.md +++ b/readme.md @@ -51,7 +51,7 @@ $ np --help Options --any-branch Allow publishing from any branch - --branch Name of the release branch (default: master) + --branch Name of the release branch (default: main | master) --no-cleanup Skips cleanup of node_modules --no-tests Skips tests --yolo Skips cleanup and testing diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 7d244ad4..570322e1 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -23,7 +23,7 @@ const cli = meow(` Options --any-branch Allow publishing from any branch - --branch Name of the release branch (default: master) + --branch Name of the release branch (default: main | master) --no-cleanup Skips cleanup of node_modules --no-tests Skips tests --yolo Skips cleanup and testing From b49b20fe4ba445619bd19950a59159afd46efaf2 Mon Sep 17 00:00:00 2001 From: Nano Miratus Date: Sat, 6 Feb 2021 11:09:42 +0100 Subject: [PATCH 018/180] Add .cjs file ending for config file locations (#596) --- readme.md | 4 ++-- source/config.js | 2 +- test/config.js | 18 ++++++++++++++++-- test/fixtures/config/homedir3/.np-config.cjs | 3 +++ test/fixtures/config/local3/.np-config.cjs | 3 +++ 5 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/config/homedir3/.np-config.cjs create mode 100644 test/fixtures/config/local3/.np-config.cjs diff --git a/readme.md b/readme.md index be065743..0868848b 100644 --- a/readme.md +++ b/readme.md @@ -81,7 +81,7 @@ Run `np` without arguments to launch the interactive UI that guides you through ## Config -`np` can be configured both locally and globally. When using the global `np` binary, you can configure any of the CLI flags in either a `.np-config.js` or `.np-config.json` file in the home directory. When using the local `np` binary, for example, in a `npm run` script, you can configure `np` by setting the flags in either a top-level `np` field in `package.json` or in a `.np-config.js` or `.np-config.json` file in the project directory. If it exists, the local installation will always take precedence. This ensures any local config matches the version of `np` it was designed for. +`np` can be configured both locally and globally. When using the global `np` binary, you can configure any of the CLI flags in either a `.np-config.js`, `.np-config.cjs` or `.np-config.json` file in the home directory. When using the local `np` binary, for example, in a `npm run` script, you can configure `np` by setting the flags in either a top-level `np` field in `package.json` or in a `.np-config.js`, `.np-config.cjs` or `.np-config.json` file in the project directory. If it exists, the local installation will always take precedence. This ensures any local config matches the version of `np` it was designed for. Currently, these are the flags you can configure: @@ -120,7 +120,7 @@ For example, this configures `np` to never use Yarn and to use `dist` as the sub } ``` -`.np-config.js` +`.np-config.js` or `.np-config.cjs` ```js module.exports = { yarn: false, diff --git a/source/config.js b/source/config.js index 11b77932..b88fcfd3 100644 --- a/source/config.js +++ b/source/config.js @@ -6,7 +6,7 @@ const {cosmiconfig} = require('cosmiconfig'); module.exports = async () => { const searchDir = isInstalledGlobally ? os.homedir() : await pkgDir(); - const searchPlaces = ['.np-config.json', '.np-config.js']; + const searchPlaces = ['.np-config.json', '.np-config.js', '.np-config.cjs']; if (!isInstalledGlobally) { searchPlaces.push('package.json'); } diff --git a/test/config.js b/test/config.js index ae02024f..3c577f69 100644 --- a/test/config.js +++ b/test/config.js @@ -8,7 +8,8 @@ const fixtureBasePath = path.resolve('test', 'fixtures', 'config'); const getConfigsWhenGlobalBinaryIsUsed = async homedirStub => { const pathsPkgDir = [path.resolve(fixtureBasePath, 'pkg-dir'), path.resolve(fixtureBasePath, 'local1'), - path.resolve(fixtureBasePath, 'local2')]; + path.resolve(fixtureBasePath, 'local2'), + path.resolve(fixtureBasePath, 'local3')]; const promises = []; pathsPkgDir.forEach(pathPkgDir => { @@ -27,7 +28,8 @@ const getConfigsWhenGlobalBinaryIsUsed = async homedirStub => { const getConfigsWhenLocalBinaryIsUsed = async pathPkgDir => { const homedirs = [path.resolve(fixtureBasePath, 'homedir1'), - path.resolve(fixtureBasePath, 'homedir2')]; + path.resolve(fixtureBasePath, 'homedir2'), + path.resolve(fixtureBasePath, 'homedir3')]; const promises = []; homedirs.forEach(homedir => { @@ -60,6 +62,13 @@ test('returns config from home directory when global binary is used and `.np-con configs.forEach(config => t.deepEqual(config, {source: 'homedir/.np-config.js'})); }); +test('returns config from home directory when global binary is used and `.np-config.cjs` exists in home directory', async t => { + const homedirStub = sinon.stub(); + homedirStub.returns(path.resolve(fixtureBasePath, 'homedir3')); + const configs = await getConfigsWhenGlobalBinaryIsUsed(homedirStub); + configs.forEach(config => t.deepEqual(config, {source: 'homedir/.np-config.cjs'})); +}); + test('returns config from package directory when local binary is used and `package.json` exists in package directory', async t => { const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'pkg-dir')); configs.forEach(config => t.deepEqual(config, {source: 'package.json'})); @@ -74,3 +83,8 @@ test('returns config from package directory when local binary is used and `.np-c const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'local2')); configs.forEach(config => t.deepEqual(config, {source: 'packagedir/.np-config.js'})); }); + +test('returns config from package directory when local binary is used and `.np-config.cjs` exists in package directory', async t => { + const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'local3')); + configs.forEach(config => t.deepEqual(config, {source: 'packagedir/.np-config.cjs'})); +}); diff --git a/test/fixtures/config/homedir3/.np-config.cjs b/test/fixtures/config/homedir3/.np-config.cjs new file mode 100644 index 00000000..7b96f65b --- /dev/null +++ b/test/fixtures/config/homedir3/.np-config.cjs @@ -0,0 +1,3 @@ +module.exports = { + source: 'homedir/.np-config.cjs' +}; diff --git a/test/fixtures/config/local3/.np-config.cjs b/test/fixtures/config/local3/.np-config.cjs new file mode 100644 index 00000000..fbf635db --- /dev/null +++ b/test/fixtures/config/local3/.np-config.cjs @@ -0,0 +1,3 @@ +module.exports = { + source: 'packagedir/.np-config.cjs' +}; From 2b9a02bb9cf8e7a42f2283f98982b42229dfece3 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 6 Feb 2021 23:16:38 +0700 Subject: [PATCH 019/180] 7.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 02eb1199..e7a4c36b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.2.0", + "version": "7.3.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From add5bd94063b090c9b59bf3c9130f79057a8bc8d Mon Sep 17 00:00:00 2001 From: Corentin Girard Date: Sun, 7 Feb 2021 06:48:31 +0100 Subject: [PATCH 020/180] Fix `--release-draft-only` flag (#594) --- source/git-util.js | 29 ++++++++++++++++++++++++++--- source/ui.js | 8 +++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index 92d01f69..f9f28d1f 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -34,8 +34,7 @@ const firstCommit = async () => { }; exports.previousTagOrFirstCommit = async () => { - const {stdout} = await execa('git', ['tag']); - const tags = stdout.split('\n'); + const tags = await exports.tagList(); if (tags.length === 0) { return; @@ -45,7 +44,15 @@ exports.previousTagOrFirstCommit = async () => { return firstCommit(); } - return tags[tags.length - 2]; + try { + // Return the tag before the latest one. + const latest = await exports.latestTag(); + const index = tags.indexOf(latest); + return tags[index - 1]; + } catch { + // Fallback to the first commit. + return firstCommit(); + } }; exports.latestTagOrFirstCommit = async () => { @@ -80,6 +87,22 @@ exports.verifyCurrentBranchIsReleaseBranch = async releaseBranch => { } }; +exports.tagList = async () => { + // Returns the list of tags, sorted by creation date in ascending order. + const {stdout} = await execa('git', ['tag', '--sort=creatordate']); + return stdout.split('\n'); +}; + +exports.isHeadDetached = async () => { + try { + // Command will fail with code 1 if the HEAD is detached. + await execa('git', ['symbolic-ref', '--quiet', 'HEAD']); + return false; + } catch { + return true; + } +}; + exports.isWorkingTreeClean = async () => { try { const {stdout: status} = await execa('git', ['status', '--porcelain']); diff --git a/source/ui.js b/source/ui.js index 5e1214cc..39d0941a 100644 --- a/source/ui.js +++ b/source/ui.js @@ -41,7 +41,9 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch if (!fromLatestTag) { const latestTag = await git.latestTag(); - const versionBumpCommitName = latestTag.slice(1); // Name v1.0.1 becomes 1.0.1 + + // 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) { @@ -49,6 +51,10 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch 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); } From 65a674ec5580f29de5c0cb0428d1cc3af78f3e81 Mon Sep 17 00:00:00 2001 From: Nano Miratus Date: Fri, 12 Feb 2021 11:28:48 +0100 Subject: [PATCH 021/180] Add support for custom commit message (#597) Co-authored-by: Sindre Sorhus Co-authored-by: Govind S --- readme.md | 2 ++ source/cli-implementation.js | 4 ++++ source/index.js | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index 0868848b..7b0e7266 100644 --- a/readme.md +++ b/readme.md @@ -64,6 +64,7 @@ $ np --help --release-draft-only Only opens a GitHub release draft --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) + --message Version bump commit message. `%s` will be replaced with version. (default: '%s' with npm and 'v%s' with yarn) Examples $ np @@ -98,6 +99,7 @@ Currently, these are the flags you can configure: - `releaseDraft` - Open a GitHub release draft after releasing (`true` by default). - `testScript` - Name of npm run script to run tests before publishing (`test` by default). - `2fa` - Enable 2FA on new packages (`true` by default) (setting this to `false` is not recommended). +- `message` - The commit message used for the version bump. Any `%s` in the string will be replaced with the new version. By default, npm uses `%s` and Yarn uses `v%s`. For example, this configures `np` to never use Yarn and to use `dist` as the subdirectory to publish: diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 570322e1..32b3da92 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -36,6 +36,7 @@ const cli = meow(` --release-draft-only Only opens a GitHub release draft for the latest published version --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) + --message Version bump commit message, '%s' will be replaced with version (default: default: '%s' with npm and 'v%s' with yarn) Examples $ np @@ -87,6 +88,9 @@ const cli = meow(` }, '2fa': { type: 'boolean' + }, + message: { + type: 'string' } } }); diff --git a/source/index.js b/source/index.js index 73785efe..b20dec43 100644 --- a/source/index.js +++ b/source/index.js @@ -184,20 +184,48 @@ module.exports = async (input = 'patch', options) => { enabled: () => options.yarn === true, skip: () => { if (options.preview) { - return `[Preview] Command not executed: yarn version --new-version ${input}.`; + let previewText = `[Preview] Command not executed: yarn version --new-version ${input}`; + + if (options.message) { + previewText += ` --message '${options.message.replace(/%s/g, input)}'`; + } + + return `${previewText}.`; } }, - task: () => exec('yarn', ['version', '--new-version', input]) + task: () => { + const args = ['version', '--new-version', input]; + + if (options.message) { + args.push('--message', options.message); + } + + return exec('yarn', args); + } }, { title: 'Bumping version using npm', enabled: () => options.yarn === false, skip: () => { if (options.preview) { - return `[Preview] Command not executed: npm version ${input}.`; + let previewText = `[Preview] Command not executed: npm version ${input}`; + + if (options.message) { + previewText += ` --message '${options.message.replace(/%s/g, input)}'`; + } + + return `${previewText}.`; } }, - task: () => exec('npm', ['version', input]) + task: () => { + const args = ['version', input]; + + if (options.message) { + args.push('--message', options.message); + } + + return exec('npm', args); + } } ]); From 6ad9d1ed11dc2a784d1abbd89ae2b7055581f651 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 12 Feb 2021 17:31:49 +0700 Subject: [PATCH 022/180] 7.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e7a4c36b..459b97d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.3.0", + "version": "7.4.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 626aed670687c23cb6bb036a2e727cfd31821d84 Mon Sep 17 00:00:00 2001 From: Bunyanuch Saengnet <53788417+bunysae@users.noreply.github.com> Date: Sun, 25 Apr 2021 06:33:36 +0000 Subject: [PATCH 023/180] Fix showing files added since the last release (#595) --- integration-test | 2 +- source/npm/util.js | 86 ++++++++++++++++++------- source/ui.js | 22 +++++-- source/util.js | 4 +- test/fixtures/npmignore/source/.dotfile | 0 test/npmignore.js | 66 ++++++++++++++++++- 6 files changed, 144 insertions(+), 36 deletions(-) create mode 100644 test/fixtures/npmignore/source/.dotfile diff --git a/integration-test b/integration-test index 1771d17d..ae82745e 160000 --- a/integration-test +++ b/integration-test @@ -1 +1 @@ -Subproject commit 1771d17d524bb33a4cfc99df36000a4e460fcde5 +Subproject commit ae82745e85ff662a569d64c896cb53b80091e654 diff --git a/source/npm/util.js b/source/npm/util.js index f4393cb3..d29f28f4 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -11,6 +11,29 @@ const ignoreWalker = require('ignore-walk'); const minimatch = require('minimatch'); const {verifyRequirementSatisfied} = require('../version'); +// According to https://docs.npmjs.com/files/package.json#files +// npm's default behavior is to ignore these files. +const filesIgnoredByDefault = [ + '.*.swp', + '.npmignore', + '.gitignore', + '._*', + '.DS_Store', + '.hg', + '.npmrc', + '.lock-wscript', + '.svn', + '.wafpickle-N', + '*.orig', + 'config.gypi', + 'CVS', + 'node_modules/**/*', + 'npm-debug.log', + 'package-lock.json', + '.git/**/*', + '.git' +]; + exports.checkConnection = () => pTimeout( (async () => { try { @@ -139,6 +162,19 @@ async function getFilesIgnoredByDotnpmignore(pkg, fileList) { return fileList.filter(minimatch.filter(getIgnoredFilesGlob(allowList, pkg.directories), {matchBase: true, dot: true})); } +function filterFileList(globArray, fileList) { + const globString = globArray.length > 1 ? `{${globArray}}` : globArray[0]; + return fileList.filter(minimatch.filter(globString, {matchBase: true, dot: true})); // eslint-disable-line unicorn/no-fn-reference-in-iterator +} + +async function getFilesIncludedByDotnpmignore(pkg, fileList) { + const allowList = await ignoreWalker({ + path: pkgDir.sync(), + ignoreFiles: ['.npmignore'] + }); + return filterFileList(allowList, fileList); +} + function getFilesNotIncludedInFilesProperty(pkg, fileList) { const globArrayForFilesAndDirectories = [...pkg.files]; const rootDir = pkgDir.sync(); @@ -154,6 +190,20 @@ function getFilesNotIncludedInFilesProperty(pkg, fileList) { return result.filter(minimatch.filter(getDefaultIncludedFilesGlob(pkg.main), {nocase: true, matchBase: true})); } +function getFilesIncludedInFilesProperty(pkg, fileList) { + const globArrayForFilesAndDirectories = [...pkg.files]; + const rootDir = pkgDir.sync(); + for (const glob of pkg.files) { + try { + if (fs.statSync(path.resolve(rootDir, glob)).isDirectory()) { + globArrayForFilesAndDirectories.push(`${glob}/**/*`); + } + } catch {} + } + + return filterFileList(globArrayForFilesAndDirectories, fileList); +} + function getDefaultIncludedFilesGlob(mainFile) { // According to https://docs.npmjs.com/files/package.json#files // npm's default behavior is to always include these files. @@ -175,29 +225,6 @@ function getDefaultIncludedFilesGlob(mainFile) { } function getIgnoredFilesGlob(globArrayFromFilesProperty, packageDirectories) { - // According to https://docs.npmjs.com/files/package.json#files - // npm's default behavior is to ignore these files. - const filesIgnoredByDefault = [ - '.*.swp', - '.npmignore', - '.gitignore', - '._*', - '.DS_Store', - '.hg', - '.npmrc', - '.lock-wscript', - '.svn', - '.wafpickle-N', - '*.orig', - 'config.gypi', - 'CVS', - 'node_modules/**/*', - 'npm-debug.log', - 'package-lock.json', - '.git/**/*', - '.git' - ]; - // Test files are assumed not to be part of the package let testDirectoriesGlob = ''; if (packageDirectories && Array.isArray(packageDirectories.test)) { @@ -223,6 +250,19 @@ exports.getNewAndUnpublishedFiles = async (pkg, newFiles = []) => { } }; +exports.getFirstTimePublishedFiles = async (pkg, newFiles = []) => { + let result; + if (pkg.files) { + result = getFilesIncludedInFilesProperty(pkg, newFiles); + } else if (npmignoreExistsInPackageRootDir()) { + result = await getFilesIncludedByDotnpmignore(pkg, newFiles); + } else { + result = newFiles; + } + + return result.filter(minimatch.filter(`!{${filesIgnoredByDefault}}`, {matchBase: true, dot: true})).filter(minimatch.filter(getDefaultIncludedFilesGlob(pkg.main), {nocase: true, matchBase: true})); +}; + exports.getRegistryUrl = async (pkgManager, pkg) => { const args = ['config', 'get', 'registry']; if (exports.isExternalRegistry(pkg)) { diff --git a/source/ui.js b/source/ui.js index 39d0941a..5114986f 100644 --- a/source/ui.js +++ b/source/ui.js @@ -79,22 +79,30 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch }; }; -const checkIgnoredFiles = async pkg => { - const ignoredFiles = await util.getNewAndUnpublishedFiles(pkg); - if (!ignoredFiles || ignoredFiles.length === 0) { +const checkNewFiles = async pkg => { + const newFiles = await util.getNewFiles(pkg); + if ((!newFiles.unpublished || newFiles.unpublished.length === 0) && (!newFiles.firstTime || newFiles.firstTime.length === 0)) { return true; } - const message = `The following new files are not already part of your published package:\n${chalk.reset(ignoredFiles.map(path => `- ${path}`).join('\n'))}`; + const messages = []; + if (newFiles.unpublished.length > 0) { + messages.push(`The following new files will not be part of your published package:\n${chalk.reset(newFiles.unpublished.map(path => `- ${path}`).join('\n'))}`); + } + + if (newFiles.firstTime.length > 0) { + messages.push(`The following new files will be published the first time:\n${chalk.reset(newFiles.firstTime.map(path => `- ${path}`).join('\n'))}`); + } + if (!isInteractive()) { - console.log(message); + console.log(messages.join('\n')); return true; } const answers = await inquirer.prompt([{ type: 'confirm', name: 'confirm', - message: `${message}\nContinue?`, + message: `${messages.join('\n')}\nContinue?`, default: false }]); @@ -112,7 +120,7 @@ module.exports = async (options, pkg) => { if (options.runPublish) { checkIgnoreStrategy(pkg); - const answerIgnoredFiles = await checkIgnoredFiles(pkg); + const answerIgnoredFiles = await checkNewFiles(pkg); if (!answerIgnoredFiles) { return { ...options, diff --git a/source/util.js b/source/util.js index 87105c69..79d329c9 100644 --- a/source/util.js +++ b/source/util.js @@ -71,9 +71,9 @@ exports.getTagVersionPrefix = pMemoize(async options => { } }); -exports.getNewAndUnpublishedFiles = async pkg => { +exports.getNewFiles = async pkg => { const listNewFiles = await gitUtil.newFilesSinceLastRelease(); - return npmUtil.getNewAndUnpublishedFiles(pkg, listNewFiles); + return {unpublished: await npmUtil.getNewAndUnpublishedFiles(pkg, listNewFiles), firstTime: await npmUtil.getFirstTimePublishedFiles(pkg, listNewFiles)}; }; exports.getPreReleasePrefix = pMemoize(async options => { diff --git a/test/fixtures/npmignore/source/.dotfile b/test/fixtures/npmignore/source/.dotfile new file mode 100644 index 00000000..e69de29b diff --git a/test/npmignore.js b/test/npmignore.js index 2769916a..c9e678b2 100644 --- a/test/npmignore.js +++ b/test/npmignore.js @@ -63,7 +63,7 @@ test('ignored test files using files attribute and .npmignore', async t => { t.deepEqual(await testedModule.getNewAndUnpublishedFiles({directories: {test: ['test-tap']}}, newFiles), ['source/ignore.txt', 'test/file.txt']); }); -test('dot files using files attribute', async t => { +test('ignored files - dot files using files attribute', async t => { const testedModule = proxyquire('../source/npm/util', { 'pkg-dir': { @@ -73,7 +73,7 @@ test('dot files using files attribute', async t => { t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source']}, ['test/.dotfile']), []); }); -test('dot files using .npmignore', async t => { +test('ignored files - dot files using .npmignore', async t => { const testedModule = proxyquire('../source/npm/util', { 'pkg-dir': { @@ -83,7 +83,7 @@ test('dot files using .npmignore', async t => { t.deepEqual(await testedModule.getNewAndUnpublishedFiles({}, ['test/.dot']), []); }); -test('ignore strategy is not used', async t => { +test('ignored files - ignore strategy is not used', async t => { const testedModule = proxyquire('../source/npm/util', { 'pkg-dir': { @@ -92,3 +92,63 @@ test('ignore strategy is not used', async t => { }); t.is(await testedModule.getNewAndUnpublishedFiles({name: 'no ignore strategy'}, newFiles), undefined); }); + +test('first time published files using file-attribute in package.json with one file', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'package') + } + }); + t.deepEqual(await testedModule.getFirstTimePublishedFiles({files: ['pay_attention.txt']}, newFiles), ['source/pay_attention.txt']); +}); + +test('first time published files using file-attribute in package.json with directory', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'package') + } + }); + t.deepEqual(await testedModule.getFirstTimePublishedFiles({files: ['source']}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt']); +}); + +test('first time published files using .npmignore', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'npmignore') + } + }); + t.deepEqual(await testedModule.getFirstTimePublishedFiles({name: 'npmignore'}, newFiles), ['source/pay_attention.txt']); +}); + +test('first time published dot files using files attribute', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'package') + } + }); + t.deepEqual(await testedModule.getFirstTimePublishedFiles({files: ['source']}, ['source/.dotfile']), ['source/.dotfile']); +}); + +test('first time published dot files using .npmignore', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'npmignore') + } + }); + t.deepEqual(await testedModule.getFirstTimePublishedFiles({}, ['source/.dotfile']), ['source/.dotfile']); +}); + +test('first time published files - ignore strategy is not used', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures') + } + }); + t.deepEqual(await testedModule.getFirstTimePublishedFiles({name: 'no ignore strategy'}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt', 'test/file.txt']); +}); From c97b1df5875767ae0676bd7e15de987724ca7f3b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 25 Apr 2021 13:37:16 +0700 Subject: [PATCH 024/180] 7.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 459b97d6..0748b3c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.4.0", + "version": "7.5.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From dddd07cf72422b518064320f13fdc8e6930eb731 Mon Sep 17 00:00:00 2001 From: Richie Bendall Date: Thu, 16 Sep 2021 21:53:41 +1200 Subject: [PATCH 025/180] Fix `--message` flag help description (#623) --- source/cli-implementation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 32b3da92..9177095e 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -36,7 +36,7 @@ const cli = meow(` --release-draft-only Only opens a GitHub release draft for the latest published version --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) - --message Version bump commit message, '%s' will be replaced with version (default: default: '%s' with npm and 'v%s' with yarn) + --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) Examples $ np From 5d2377ff7510bc05b127e2c5c5abd6ce0f0657dd Mon Sep 17 00:00:00 2001 From: Itai Steinherz Date: Fri, 19 Nov 2021 15:07:35 +0200 Subject: [PATCH 026/180] Fix files prompt when no new or unpublished files can be found (#625) --- package.json | 5 +++++ source/npm/util.js | 2 ++ test/npmignore.js | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0748b3c2..d7a4d69c 100644 --- a/package.json +++ b/package.json @@ -83,5 +83,10 @@ "!test/fixtures", "!integration-test" ] + }, + "xo": { + "ignores": [ + "integration-test" + ] } } diff --git a/source/npm/util.js b/source/npm/util.js index d29f28f4..237bbe35 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -248,6 +248,8 @@ exports.getNewAndUnpublishedFiles = async (pkg, newFiles = []) => { if (npmignoreExistsInPackageRootDir()) { return getFilesIgnoredByDotnpmignore(pkg, newFiles); } + + return []; }; exports.getFirstTimePublishedFiles = async (pkg, newFiles = []) => { diff --git a/test/npmignore.js b/test/npmignore.js index c9e678b2..90ee91ed 100644 --- a/test/npmignore.js +++ b/test/npmignore.js @@ -90,7 +90,7 @@ test('ignored files - ignore strategy is not used', async t => { sync: () => path.resolve('test', 'fixtures') } }); - t.is(await testedModule.getNewAndUnpublishedFiles({name: 'no ignore strategy'}, newFiles), undefined); + t.deepEqual(await testedModule.getNewAndUnpublishedFiles({name: 'no ignore strategy'}, newFiles), []); }); test('first time published files using file-attribute in package.json with one file', async t => { From b1939c7549784c1a085e83dee8c945a2e2ef0920 Mon Sep 17 00:00:00 2001 From: Itai Steinherz Date: Fri, 19 Nov 2021 15:20:23 +0200 Subject: [PATCH 027/180] 7.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d7a4d69c..7fbc4ff7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.5.0", + "version": "7.6.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 20117a49c7e4e144c62bf070ea0f8e5ab1759e02 Mon Sep 17 00:00:00 2001 From: Roy Revelt Date: Sat, 19 Mar 2022 17:30:14 +0000 Subject: [PATCH 028/180] Fix "pattern is too long" error when running `np` (#633) --- source/npm/util.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index 237bbe35..9b5f0d0d 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -154,16 +154,20 @@ function npmignoreExistsInPackageRootDir() { return fs.existsSync(path.resolve(rootDir, '.npmignore')); } +function excludeGitAndNodeModulesPaths(singlePath) { + return !singlePath.startsWith('.git/') && !singlePath.startsWith('node_modules/'); +} + async function getFilesIgnoredByDotnpmignore(pkg, fileList) { - const allowList = await ignoreWalker({ + const allowList = (await ignoreWalker({ path: pkgDir.sync(), ignoreFiles: ['.npmignore'] - }); + })).filter(singlePath => excludeGitAndNodeModulesPaths(singlePath)); return fileList.filter(minimatch.filter(getIgnoredFilesGlob(allowList, pkg.directories), {matchBase: true, dot: true})); } function filterFileList(globArray, fileList) { - const globString = globArray.length > 1 ? `{${globArray}}` : globArray[0]; + const globString = globArray.length > 1 ? `{${globArray.filter(singlePath => excludeGitAndNodeModulesPaths(singlePath))}}` : globArray[0]; return fileList.filter(minimatch.filter(globString, {matchBase: true, dot: true})); // eslint-disable-line unicorn/no-fn-reference-in-iterator } From 9cff6da0af6d94466cd63242803206a71467f3be Mon Sep 17 00:00:00 2001 From: Bunyanuch Saengnet <53788417+bunysae@users.noreply.github.com> Date: Sat, 19 Mar 2022 17:39:12 +0000 Subject: [PATCH 029/180] Fix error "glob pattern string required" (#616) Co-authored-by: Itai Steinherz Co-authored-by: Sindre Sorhus --- integration-test | 2 +- source/npm/util.js | 4 ++++ test/npmignore.js | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/integration-test b/integration-test index ae82745e..b2492e19 160000 --- a/integration-test +++ b/integration-test @@ -1 +1 @@ -Subproject commit ae82745e85ff662a569d64c896cb53b80091e654 +Subproject commit b2492e190f3864f96955a7acb4349bb9722530b9 diff --git a/source/npm/util.js b/source/npm/util.js index 9b5f0d0d..1443cc01 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -167,6 +167,10 @@ async function getFilesIgnoredByDotnpmignore(pkg, fileList) { } function filterFileList(globArray, fileList) { + if (globArray.length === 0) { + return []; + } + const globString = globArray.length > 1 ? `{${globArray.filter(singlePath => excludeGitAndNodeModulesPaths(singlePath))}}` : globArray[0]; return fileList.filter(minimatch.filter(globString, {matchBase: true, dot: true})); // eslint-disable-line unicorn/no-fn-reference-in-iterator } diff --git a/test/npmignore.js b/test/npmignore.js index 90ee91ed..8bb40c97 100644 --- a/test/npmignore.js +++ b/test/npmignore.js @@ -152,3 +152,23 @@ test('first time published files - ignore strategy is not used', async t => { }); t.deepEqual(await testedModule.getFirstTimePublishedFiles({name: 'no ignore strategy'}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt', 'test/file.txt']); }); + +test('first time published files - empty files property', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'package') + } + }); + t.deepEqual(await testedModule.getFirstTimePublishedFiles({files: []}, newFiles), []); +}); + +test('first time published files - .npmignore excludes everything', async t => { + const testedModule = proxyquire('../source/npm/util', { + 'pkg-dir': + { + sync: () => path.resolve('test', 'fixtures', 'npmignore') + } + }); + t.deepEqual(await testedModule.getFirstTimePublishedFiles({name: 'excluded everything'}, ['source/ignore.txt']), []); +}); From 845db26619bda9efe5b300ec9c7ad45379e3e7d4 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 20 Mar 2022 00:40:13 +0700 Subject: [PATCH 030/180] Meta tweaks --- .github/funding.yml | 1 - .github/workflows/main.yml | 2 +- integration-test | 2 +- readme.md | 4 ++-- 4 files changed, 4 insertions(+), 5 deletions(-) delete mode 100644 .github/funding.yml diff --git a/.github/funding.yml b/.github/funding.yml deleted file mode 100644 index 0038dab5..00000000 --- a/.github/funding.yml +++ /dev/null @@ -1 +0,0 @@ -github: sindresorhus diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96917392..28f5aa6c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v2 with: submodules: true - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - run: git config --global user.name "Github Actions" diff --git a/integration-test b/integration-test index b2492e19..1771d17d 160000 --- a/integration-test +++ b/integration-test @@ -1 +1 @@ -Subproject commit b2492e190f3864f96955a7acb4349bb9722530b9 +Subproject commit 1771d17d524bb33a4cfc99df36000a4e460fcde5 diff --git a/readme.md b/readme.md index 7b0e7266..d3d2e7dc 100644 --- a/readme.md +++ b/readme.md @@ -34,8 +34,8 @@ ## Install -``` -$ npm install --global np +```sh +npm install --global np ``` ## Usage From 9b7df306c80a6b60c87b1444f875bdb4568dbe8a Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 20 Mar 2022 00:56:42 +0700 Subject: [PATCH 031/180] 7.6.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7fbc4ff7..531d99f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.6.0", + "version": "7.6.1", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 387dddca8411830a109d8716b2c4c5d71a1775df Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 20 Mar 2022 01:06:43 +0700 Subject: [PATCH 032/180] Fix tests --- integration-test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-test b/integration-test index 1771d17d..b2492e19 160000 --- a/integration-test +++ b/integration-test @@ -1 +1 @@ -Subproject commit 1771d17d524bb33a4cfc99df36000a4e460fcde5 +Subproject commit b2492e190f3864f96955a7acb4349bb9722530b9 From 6c871f9cc7175559b5d67be896e868d16a9ef432 Mon Sep 17 00:00:00 2001 From: trickypr <23250792+trickypr@users.noreply.github.com> Date: Thu, 23 Jun 2022 04:03:35 +1000 Subject: [PATCH 033/180] Work around npm bug with error reporting (#645) --- source/npm/util.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/source/npm/util.js b/source/npm/util.js index 1443cc01..7e70c3c9 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -95,7 +95,18 @@ exports.prereleaseTags = async packageName => { tags = Object.keys(JSON.parse(stdout)) .filter(tag => tag !== 'latest'); } catch (error) { - if (((JSON.parse(error.stdout) || {}).error || {}).code !== 'E404') { + // HACK: NPM is mixing JSON with plain text errors. Luckily, the error + // always starts with 'npm ERR!' (unless you have a debugger attached) + // so as a solution, until npm/cli#2740 is fixed, we can remove anything + // starting with 'npm ERR!' + /** @type {string} */ + const errorMessage = error.stderr; + const errorJSON = errorMessage + .split('\n') + .filter(error => !error.startsWith('npm ERR!')) + .join('\n'); + + if (((JSON.parse(errorJSON) || {}).error || {}).code !== 'E404') { throw error; } } From 95622c0adbd64920b3d86576784460dd41b81d0e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 22 Jun 2022 20:04:52 +0200 Subject: [PATCH 034/180] 7.6.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 531d99f2..d4d876c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.6.1", + "version": "7.6.2", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 0f5dd3348071c24198214202ded8c811a48a4054 Mon Sep 17 00:00:00 2001 From: Timon Jurschitsch <103483059+DerTimonius@users.noreply.github.com> Date: Tue, 27 Dec 2022 14:45:50 +0100 Subject: [PATCH 035/180] Fix npm 9 compatibility (#666) --- source/npm/util.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/npm/util.js b/source/npm/util.js index 7e70c3c9..2d594761 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -10,6 +10,7 @@ const pkgDir = require('pkg-dir'); const ignoreWalker = require('ignore-walk'); const minimatch = require('minimatch'); const {verifyRequirementSatisfied} = require('../version'); +const semver = require('semver'); // According to https://docs.npmjs.com/files/package.json#files // npm's default behavior is to ignore these files. @@ -68,7 +69,8 @@ exports.collaborators = async pkg => { const packageName = pkg.name; ow(packageName, ow.string); - const args = ['access', 'ls-collaborators', packageName]; + const npmVersion = await exports.version(); + const args = semver.satisfies(npmVersion, '>=9.0.0') ? ['access', 'list', 'collaborators', packageName, '--json'] : ['access', 'ls-collaborators', packageName]; if (exports.isExternalRegistry(pkg)) { args.push('--registry', pkg.publishConfig.registry); } From 9fb0128a64b5975d4913c439ccae3a27a32fa61a Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 27 Dec 2022 14:47:37 +0100 Subject: [PATCH 036/180] 7.6.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d4d876c3..6693f1d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.6.2", + "version": "7.6.3", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 62db480f7131acca4f6b4b099f62a5f9ad8c6785 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Fri, 3 Feb 2023 21:56:53 +0800 Subject: [PATCH 037/180] Add "what np isn't" section to the readme (#672) Co-authored-by: Sindre Sorhus --- readme.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/readme.md b/readme.md index d3d2e7dc..2195393a 100644 --- a/readme.md +++ b/readme.md @@ -26,6 +26,12 @@ - See exactly what will be executed with [preview mode](https://github.com/sindresorhus/np/issues/391), without pushing or publishing anything remotely - Supports [GitHub Packages](https://github.com/features/packages) +### Why not + +- Monorepos are not supported. +- Custom registries are not supported ([but could be with your help](https://github.com/sindresorhus/np/issues/420)). +- CI is [not an ideal environment](https://github.com/sindresorhus/np/issues/619#issuecomment-994493179) for `np`. It's meant to be used locally as an interactive tool. + ## Prerequisite - Node.js 10 or later From 3770647fbbfa1804ba56a0dd5800793ff3f4680c Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 1 Mar 2023 10:54:17 +0700 Subject: [PATCH 038/180] Meta tweaks --- readme.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/readme.md b/readme.md index 2195393a..e0018599 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,26 @@ > A better `npm publish` +--- + +
+

+

+ + My open source work is supported by the community + +

+ Special thanks to: +
+
+ + + +

+
+ +--- + ## Why From e71f6911bc7dc059d33983d41117e50a12744e35 Mon Sep 17 00:00:00 2001 From: Tommy Date: Mon, 20 Mar 2023 01:24:33 -0500 Subject: [PATCH 039/180] Fix UI prompt for new publicly scoped packages (#677) --- readme.md | 2 ++ source/npm/handle-npm-error.js | 6 ++++++ source/ui.js | 1 + 3 files changed, 9 insertions(+) diff --git a/readme.md b/readme.md index e0018599..de2d6736 100644 --- a/readme.md +++ b/readme.md @@ -243,6 +243,8 @@ To publish [scoped packages](https://docs.npmjs.com/misc/scope#publishing-public } ``` +If publishing a scoped package for the first time, `np` will prompt you to ask if you want to publish it publicly. + ### Private Org-scoped packages To publish a [private Org-scoped package](https://docs.npmjs.com/creating-and-publishing-an-org-scoped-package#publishing-a-private-org-scoped-package), you need to set the access level to `restricted`. You can do that by adding the following to your `package.json`: diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index 36586c93..fa191c51 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -25,6 +25,12 @@ const handleNpmError = (error, task, message, executor) => { ); } + // Attempting to privately publish a scoped package without the correct npm plan + // https://stackoverflow.com/a/44862841/10292952 + if (error.code === 402 || error.stderr.includes('npm ERR! 402 Payment Required')) { + throw new Error('You cannot publish a privately scoped package without a paid plan. Did you mean to publish publicly?'); + } + return throwError(error); }; diff --git a/source/ui.js b/source/ui.js index 5114986f..4d4001ba 100644 --- a/source/ui.js +++ b/source/ui.js @@ -284,6 +284,7 @@ module.exports = async (options, pkg) => { ...options, version: answers.version || answers.customVersion || options.version, tag: answers.tag || answers.customTag || options.tag, + publishScoped: answers.publishScoped, confirm: true, repoUrl, releaseNotes From aa23a92bcaf710068f01ed5fe50d3039b510e223 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 20 Mar 2023 14:11:13 +0700 Subject: [PATCH 040/180] 7.6.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6693f1d1..28c8493b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.6.3", + "version": "7.6.4", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From f992ec1953d4b57653e8bf8cacd8b7969abebbbf Mon Sep 17 00:00:00 2001 From: Tommy Date: Wed, 29 Mar 2023 07:29:30 -0500 Subject: [PATCH 041/180] Fix typo (#680) --- source/ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/ui.js b/source/ui.js index 4d4001ba..b8ffe2e8 100644 --- a/source/ui.js +++ b/source/ui.js @@ -91,7 +91,7 @@ const checkNewFiles = async pkg => { } if (newFiles.firstTime.length > 0) { - messages.push(`The following new files will be published the first time:\n${chalk.reset(newFiles.firstTime.map(path => `- ${path}`).join('\n'))}`); + messages.push(`The following new files will be published for the first time:\n${chalk.reset(newFiles.firstTime.map(path => `- ${path}`).join('\n'))}`); } if (!isInteractive()) { From a0a17fdc8c470d396a3f7efc671d2c238842a54d Mon Sep 17 00:00:00 2001 From: Tommy Date: Wed, 29 Mar 2023 21:33:53 -0500 Subject: [PATCH 042/180] Show new version in non-interactive mode (#679) --- source/prerequisite-tasks.js | 10 +--------- source/ui.js | 5 ++++- source/version.js | 12 +++++++++++- test/version.js | 21 ++++++++++++++++++++- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 065c6a35..56b12aa7 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -59,15 +59,7 @@ module.exports = (input, pkg, options) => { { title: 'Validate version', task: () => { - if (!version.isValidInput(input)) { - throw new Error(`Version should be either ${version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`); - } - - newVersion = version(pkg.version).getNewVersionFrom(input); - - if (version(pkg.version).isLowerThanOrEqualTo(newVersion)) { - throw new Error(`New version \`${newVersion}\` should be higher than current version \`${pkg.version}\``); - } + newVersion = version.getAndValidateNewVersionFrom(input, pkg.version); } }, { diff --git a/source/ui.js b/source/ui.js index b8ffe2e8..b1d0a9c1 100644 --- a/source/ui.js +++ b/source/ui.js @@ -132,7 +132,10 @@ module.exports = async (options, pkg) => { if (options.releaseDraftOnly) { console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); } else { - console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); + const newVersion = options.version ? version.getAndValidateNewVersionFrom(options.version, oldVersion) : undefined; + const versionText = chalk.dim(`(current: ${oldVersion}${newVersion ? `, next: ${prettyVersionDiff(oldVersion, newVersion)}` : ''}${chalk.dim(')')}`); + + console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${versionText}\n`); } const prompts = [ diff --git a/source/version.js b/source/version.js index c12d5425..a0504466 100644 --- a/source/version.js +++ b/source/version.js @@ -20,7 +20,7 @@ class Version { getNewVersionFrom(input) { module.exports.validate(this.version); if (!module.exports.isValidInput(input)) { - throw new Error(`Version should be either ${module.exports.SEMVER_INCREMENTS.join(', ')} or a valid semver version.`); + throw new Error(`Version should be either ${module.exports.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`); } return module.exports.SEMVER_INCREMENTS.includes(input) ? semver.inc(this.version, input) : input; @@ -64,3 +64,13 @@ module.exports.verifyRequirementSatisfied = (dependency, version) => { throw new Error(`Please upgrade to ${dependency}${depRange}`); } }; + +module.exports.getAndValidateNewVersionFrom = (input, version) => { + const newVersion = module.exports(version).getNewVersionFrom(input); + + if (module.exports(version).isLowerThanOrEqualTo(newVersion)) { + throw new Error(`New version \`${newVersion}\` should be higher than current version \`${version}\``); + } + + return newVersion; +}; diff --git a/test/version.js b/test/version.js index 48cf4189..07b8c056 100644 --- a/test/version.js +++ b/test/version.js @@ -49,7 +49,7 @@ test('version.isPrereleaseOrIncrement', t => { }); test('version.getNewVersionFrom', t => { - const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease or a valid semver version.'; + const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.'; t.throws(() => version('1.0.0').getNewVersionFrom('patchxxx'), message); t.throws(() => version('1.0.0').getNewVersionFrom('1.0.0.0'), message); @@ -118,3 +118,22 @@ test('version.satisfies', t => { t.false(version('3.0.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.false(version('3.10.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); }); + +test('version.getAndValidateNewVersionFrom', t => { + t.is(version.getAndValidateNewVersionFrom('patch', '1.0.0'), '1.0.1'); + + t.throws( + () => version.getAndValidateNewVersionFrom('patch', '1'), + 'Version should be a valid semver version.' + ); + + t.throws( + () => version.getAndValidateNewVersionFrom('lol', '1.0.0'), + `Version should be either ${version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.` + ); + + t.throws( + () => version.getAndValidateNewVersionFrom('1.0.0', '2.0.0'), + 'New version `1.0.0` should be higher than current version `2.0.0`' + ); +}); From 8fcca96d4f14a256a576c0a32dd8f9faaef3674b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 30 Mar 2023 11:46:20 +0900 Subject: [PATCH 043/180] 7.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 28c8493b..22158766 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.6.4", + "version": "7.7.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 6867fb9b6c23d32eea82689daad272b3340f85b1 Mon Sep 17 00:00:00 2001 From: Tommy Date: Sat, 1 Apr 2023 13:05:56 -0500 Subject: [PATCH 044/180] Add check for new dependencies (#681) --- source/cli-implementation.js | 4 ++-- source/git-util.js | 12 ++++++++++++ source/index.js | 4 ++-- source/ui.js | 24 ++++++++++++++++++------ source/util.js | 22 ++++++++++++++++++++-- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 9177095e..2e541542 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -98,7 +98,7 @@ const cli = meow(` updateNotifier({pkg: cli.pkg}).notify(); (async () => { - const pkg = util.readPkg(); + const {pkg, pkgPath} = util.readPkg(); const defaultFlags = { cleanup: true, @@ -139,7 +139,7 @@ updateNotifier({pkg: cli.pkg}).notify(); version, runPublish, branch - }, pkg); + }, {pkg, pkgPath}); if (!options.confirm) { process.exit(0); diff --git a/source/git-util.js b/source/git-util.js index f9f28d1f..693c88dd 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -1,4 +1,5 @@ 'use strict'; +const path = require('path'); const execa = require('execa'); const escapeStringRegexp = require('escape-string-regexp'); const ignoreWalker = require('ignore-walk'); @@ -10,6 +11,11 @@ exports.latestTag = async () => { return stdout; }; +exports.root = async () => { + const {stdout} = await execa('git', ['rev-parse', '--show-toplevel']); + return stdout; +}; + exports.newFilesSinceLastRelease = async () => { try { const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await this.latestTag(), 'HEAD']); @@ -28,6 +34,12 @@ exports.newFilesSinceLastRelease = async () => { } }; +exports.readFileFromLastRelease = async file => { + const filePathFromRoot = path.relative(await exports.root(), file); + const {stdout: oldFile} = await execa('git', ['show', `${await this.latestTag()}:${filePathFromRoot}`]); + return oldFile; +}; + const firstCommit = async () => { const {stdout} = await execa('git', ['rev-list', '--max-parents=0', 'HEAD']); return stdout; diff --git a/source/index.js b/source/index.js index b20dec43..7d25820c 100644 --- a/source/index.js +++ b/source/index.js @@ -47,7 +47,7 @@ module.exports = async (input = 'patch', options) => { options.cleanup = false; } - const pkg = util.readPkg(options.contents); + const {pkg} = util.readPkg(options.contents); const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; const pkgManager = options.yarn === true ? 'yarn' : 'npm'; @@ -75,7 +75,7 @@ module.exports = async (input = 'patch', options) => { const versionInLatestTag = latestTag.slice(tagVersionPrefix.length); try { - if (versionInLatestTag === util.readPkg().version && + if (versionInLatestTag === util.readPkg().pkg.version && versionInLatestTag !== pkg.version) { // Verify that the package's version has been bumped before deleting the last tag and commit. await git.deleteTag(latestTag); await git.removeLastCommit(); diff --git a/source/ui.js b/source/ui.js index b1d0a9c1..247cbd39 100644 --- a/source/ui.js +++ b/source/ui.js @@ -79,19 +79,31 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch }; }; -const checkNewFiles = async pkg => { +const checkNewFilesAndDependencies = async (pkg, pkgPath) => { const newFiles = await util.getNewFiles(pkg); - if ((!newFiles.unpublished || newFiles.unpublished.length === 0) && (!newFiles.firstTime || newFiles.firstTime.length === 0)) { + const newDependencies = await util.getNewDependencies(pkg, pkgPath); + + 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${chalk.reset(newFiles.unpublished.map(path => `- ${path}`).join('\n'))}`); + messages.push(`The following new files will not be part of your published package:\n${util.joinList(newFiles.unpublished)}`); } if (newFiles.firstTime.length > 0) { - messages.push(`The following new files will be published for the first time:\n${chalk.reset(newFiles.firstTime.map(path => `- ${path}`).join('\n'))}`); + messages.push(`The following new files will be published for the first time:\n${util.joinList(newFiles.firstTime)}`); + } + + if (newDependencies.length > 0) { + messages.push(`The following new dependencies will be part of your published package:\n${util.joinList(newDependencies)}`); } if (!isInteractive()) { @@ -109,7 +121,7 @@ const checkNewFiles = async pkg => { return answers.confirm; }; -module.exports = async (options, pkg) => { +module.exports = async (options, {pkg, pkgPath}) => { const oldVersion = pkg.version; const extraBaseUrls = ['gitlab.com']; const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); @@ -120,7 +132,7 @@ module.exports = async (options, pkg) => { if (options.runPublish) { checkIgnoreStrategy(pkg); - const answerIgnoredFiles = await checkNewFiles(pkg); + const answerIgnoredFiles = await checkNewFilesAndDependencies(pkg, pkgPath); if (!answerIgnoredFiles) { return { ...options, diff --git a/source/util.js b/source/util.js index 79d329c9..0b683fc6 100644 --- a/source/util.js +++ b/source/util.js @@ -6,6 +6,7 @@ const execa = require('execa'); const pMemoize = require('p-memoize'); const {default: ow} = require('ow'); const pkgDir = require('pkg-dir'); +const chalk = require('chalk'); const gitUtil = require('./git-util'); const npmUtil = require('./npm/util'); @@ -16,11 +17,11 @@ exports.readPkg = packagePath => { throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); } - const {packageJson} = readPkgUp.sync({ + const {packageJson, path} = readPkgUp.sync({ cwd: packagePath }); - return packageJson; + return {pkg: packageJson, pkgPath: path}; }; exports.linkifyIssues = (url, message) => { @@ -71,11 +72,28 @@ exports.getTagVersionPrefix = pMemoize(async options => { } }); +exports.joinList = list => chalk.reset(list.map(item => `- ${item}`).join('\n')); + exports.getNewFiles = async pkg => { const listNewFiles = await gitUtil.newFilesSinceLastRelease(); return {unpublished: await npmUtil.getNewAndUnpublishedFiles(pkg, listNewFiles), firstTime: await npmUtil.getFirstTimePublishedFiles(pkg, listNewFiles)}; }; +exports.getNewDependencies = async (newPkg, pkgPath) => { + let oldPkg = await gitUtil.readFileFromLastRelease(pkgPath); + oldPkg = JSON.parse(oldPkg); + + const newDependencies = []; + + for (const dependency of Object.keys(newPkg.dependencies)) { + if (!oldPkg.dependencies[dependency]) { + newDependencies.push(dependency); + } + } + + return newDependencies; +}; + exports.getPreReleasePrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); From 72879e0865bf044fb612a429a945922fe818654a Mon Sep 17 00:00:00 2001 From: Tommy Date: Mon, 3 Apr 2023 00:56:22 -0500 Subject: [PATCH 045/180] Move to ESM, update dependencies (#683) --- .github/workflows/main.yml | 12 +- .gitmodules | 3 - integration-test | 1 - .../private-packages.png | Bin screenshot-ui.png => media/screenshot-ui.png | Bin screenshot.gif => media/screenshot.gif | Bin package.json | 102 +++--- readme.md | 20 +- source/cli-implementation.js | 85 ++--- source/cli.js | 12 +- source/config.js | 29 +- source/git-tasks.js | 17 +- source/git-util.js | 103 +++--- source/index.js | 169 ++++----- source/npm/enable-2fa.js | 17 +- source/npm/handle-npm-error.js | 17 +- source/npm/publish.js | 17 +- source/npm/util.js | 106 +++--- source/prerequisite-tasks.js | 56 +-- source/pretty-version-diff.js | 11 +- source/release-task-helper.js | 19 +- source/ui.js | 242 ++++++------ source/util.js | 48 ++- source/version.js | 74 ++-- test/_utils.js | 50 +++ test/config.js | 138 +++---- test/fixtures/config/homedir4/.np-config.js | 3 + test/fixtures/config/homedir5/.np-config.mjs | 3 + test/fixtures/config/local4/.np-config.js | 3 + test/fixtures/config/local4/package.json | 4 + test/fixtures/config/local5/.np-config.mjs | 3 + test/fixtures/config/package.json | 3 + test/fixtures/listr-renderer.js | 12 +- test/git-tasks.js | 147 ++++---- test/hyperlinks.js | 2 +- test/index.js | 94 ++--- test/integration.js | 105 +++++- test/npmignore.js | 272 ++++++-------- test/prefix.js | 19 +- test/preid.js | 10 +- test/prerequisite-tasks.js | 345 ++++++++++-------- test/version.js | 162 ++++---- 42 files changed, 1362 insertions(+), 1173 deletions(-) delete mode 100644 .gitmodules delete mode 160000 integration-test rename private-packages.png => media/private-packages.png (100%) rename screenshot-ui.png => media/screenshot-ui.png (100%) rename screenshot.gif => media/screenshot.gif (100%) create mode 100644 test/_utils.js create mode 100644 test/fixtures/config/homedir4/.np-config.js create mode 100644 test/fixtures/config/homedir5/.np-config.mjs create mode 100644 test/fixtures/config/local4/.np-config.js create mode 100644 test/fixtures/config/local4/package.json create mode 100644 test/fixtures/config/local5/.np-config.mjs create mode 100644 test/fixtures/config/package.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 28f5aa6c..2d58c737 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,14 +10,12 @@ jobs: fail-fast: false matrix: node-version: - - 14 - - 12 - - 10 + - 19 + - 18 + - 16 steps: - - uses: actions/checkout@v2 - with: - submodules: true - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - run: git config --global user.name "Github Actions" diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 18f98e71..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "integration-test"] - path = integration-test - url = https://github.com/bunysae/np_integration_test diff --git a/integration-test b/integration-test deleted file mode 160000 index b2492e19..00000000 --- a/integration-test +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b2492e190f3864f96955a7acb4349bb9722530b9 diff --git a/private-packages.png b/media/private-packages.png similarity index 100% rename from private-packages.png rename to media/private-packages.png diff --git a/screenshot-ui.png b/media/screenshot-ui.png similarity index 100% rename from screenshot-ui.png rename to media/screenshot-ui.png diff --git a/screenshot.gif b/media/screenshot.gif similarity index 100% rename from screenshot.gif rename to media/screenshot.gif diff --git a/package.json b/package.json index 22158766..219d79d7 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,16 @@ "license": "MIT", "repository": "sindresorhus/np", "funding": "https://github.com/sindresorhus/np?sponsor=1", + "type": "module", "bin": "source/cli.js", "engines": { - "node": ">=10", - "npm": ">=6.8.0", + "node": ">=16.6.0", + "npm": ">=7.19.0", "git": ">=2.11.0", "yarn": ">=1.7.0" }, "scripts": { - "test": "xo && FORCE_HYPERLINK=1 ava" + "test": "xo && ava && ava test/integration.js --no-worker-threads" }, "files": [ "source" @@ -30,63 +31,60 @@ "commit" ], "dependencies": { - "@samverschueren/stream-to-observable": "^0.3.1", - "any-observable": "^0.5.1", - "async-exit-hook": "^2.0.1", - "chalk": "^4.1.0", - "cosmiconfig": "^7.0.0", - "del": "^6.0.0", - "escape-goat": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "execa": "^5.0.0", + "chalk": "^5.2.0", + "cosmiconfig": "^8.1.3", + "del": "^7.0.0", + "escape-goat": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "execa": "^7.1.1", + "exit-hook": "^3.2.0", "github-url-from-git": "^1.5.0", - "has-yarn": "^2.1.0", - "hosted-git-info": "^3.0.7", - "ignore-walk": "^3.0.3", - "import-local": "^3.0.2", - "inquirer": "^7.3.3", - "is-installed-globally": "^0.3.2", - "is-interactive": "^1.0.0", - "is-scoped": "^2.1.0", - "issue-regex": "^3.1.0", + "has-yarn": "^3.0.0", + "hosted-git-info": "^6.1.1", + "ignore-walk": "^6.0.2", + "import-local": "^3.1.0", + "inquirer": "^9.1.5", + "is-installed-globally": "^0.4.0", + "is-interactive": "^2.0.0", + "is-scoped": "^3.0.0", + "issue-regex": "^4.1.0", "listr": "^0.14.3", "listr-input": "^0.2.1", - "log-symbols": "^4.0.0", - "meow": "^8.1.0", - "minimatch": "^3.0.4", - "new-github-release-url": "^1.0.0", - "npm-name": "^6.0.1", - "onetime": "^5.1.2", - "open": "^7.3.0", - "ow": "^0.21.0", - "p-memoize": "^4.0.1", - "p-timeout": "^4.1.0", - "pkg-dir": "^5.0.0", - "read-pkg-up": "^7.0.1", - "rxjs": "^6.6.3", - "semver": "^7.3.4", - "split": "^1.0.1", - "symbol-observable": "^3.0.0", - "terminal-link": "^2.1.1", - "update-notifier": "^5.0.1" + "log-symbols": "^5.1.0", + "meow": "^11.0.0", + "minimatch": "^8.0.2", + "new-github-release-url": "^2.0.0", + "npm-name": "^7.1.0", + "onetime": "^6.0.0", + "open": "^9.1.0", + "ow": "^1.1.1", + "p-memoize": "^7.1.1", + "p-timeout": "^6.1.1", + "pkg-dir": "^7.0.0", + "read-pkg-up": "^9.1.0", + "rxjs": "^7.8.0", + "semver": "^7.3.8", + "symbol-observable": "^4.0.0", + "terminal-link": "^3.0.0", + "update-notifier": "^6.0.2" }, "devDependencies": { - "ava": "^2.3.0", - "execa_test_double": "^4.0.1", - "mockery": "^2.1.0", - "proxyquire": "^2.1.3", - "sinon": "^9.2.2", - "xo": "^0.36.1" + "ava": "^5.2.0", + "common-tags": "^1.8.2", + "esmock": "^2.2.0", + "fs-extra": "^11.1.1", + "sinon": "^15.0.3", + "xo": "^0.53.1" }, "ava": { + "environmentVariables": { + "FORCE_HYPERLINK": "1" + }, "files": [ - "!test/fixtures", - "!integration-test" - ] - }, - "xo": { - "ignores": [ - "integration-test" + "!test/integration.js" + ], + "nodeArguments": [ + "--loader=esmock" ] } } diff --git a/readme.md b/readme.md index de2d6736..f04dcf7a 100644 --- a/readme.md +++ b/readme.md @@ -22,7 +22,7 @@ --- - + ## Why @@ -54,8 +54,8 @@ ## Prerequisite -- Node.js 10 or later -- npm 6.8.0 or later +- Node.js 16 or later +- npm 7.19.0 or later - Git 2.11 or later ## Install @@ -104,11 +104,11 @@ $ np --help Run `np` without arguments to launch the interactive UI that guides you through publishing a new version. - + ## Config -`np` can be configured both locally and globally. When using the global `np` binary, you can configure any of the CLI flags in either a `.np-config.js`, `.np-config.cjs` or `.np-config.json` file in the home directory. When using the local `np` binary, for example, in a `npm run` script, you can configure `np` by setting the flags in either a top-level `np` field in `package.json` or in a `.np-config.js`, `.np-config.cjs` or `.np-config.json` file in the project directory. If it exists, the local installation will always take precedence. This ensures any local config matches the version of `np` it was designed for. +`np` can be configured both globally and locally. When using the global `np` binary, you can configure any of the CLI flags in either a `.np-config.js` (as CJS), `.np-config.cjs`, `.np-config.mjs`, or `.np-config.json` file in the home directory. When using the local `np` binary, for example, in a `npm run` script, you can configure `np` by setting the flags in either a top-level `np` field in `package.json` or in one of the aforementioned file types in the project directory. If it exists, the local installation will always take precedence. This ensures any local config matches the version of `np` it was designed for. Currently, these are the flags you can configure: @@ -156,6 +156,14 @@ module.exports = { }; ``` +`.np-config.mjs` +```js +export default { + yarn: false, + contents: 'dist' +}; +``` + _**Note:** The global config only applies when using the global `np` binary, and is never inherited when using a local binary._ ## Tips @@ -226,7 +234,7 @@ $ yarn config set version-sign-git-tag true ### Private packages - + You can use `np` for packages that aren't publicly published to npm (perhaps installed from a private git repo). diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 2e541542..3b394f92 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -1,25 +1,25 @@ #!/usr/bin/env node -'use strict'; // eslint-disable-next-line import/no-unassigned-import -require('symbol-observable'); // Important: This needs to be first to prevent weird Observable incompatibilities -const logSymbols = require('log-symbols'); -const meow = require('meow'); -const updateNotifier = require('update-notifier'); -const hasYarn = require('has-yarn'); -const config = require('./config'); -const git = require('./git-util'); -const {isPackageNameAvailable} = require('./npm/util'); -const version = require('./version'); -const util = require('./util'); -const ui = require('./ui'); -const np = require('.'); +import 'symbol-observable'; // Important: This needs to be first to prevent weird Observable incompatibilities +import logSymbols from 'log-symbols'; +import meow from 'meow'; +import updateNotifier from 'update-notifier'; +import hasYarn from 'has-yarn'; +import {gracefulExit} from 'exit-hook'; +import config from './config.js'; +import * as git from './git-util.js'; +import {isPackageNameAvailable} from './npm/util.js'; +import Version from './version.js'; +import * as util from './util.js'; +import ui from './ui.js'; +import np from './index.js'; const cli = meow(` Usage $ np Version can be: - ${version.SEMVER_INCREMENTS.join(' | ')} | 1.2.3 + ${Version.SEMVER_INCREMENTS.join(' | ')} | 1.2.3 Options --any-branch Allow publishing from any branch @@ -45,60 +45,61 @@ const cli = meow(` $ np 1.0.2-beta.3 --tag=beta $ np 1.0.2-beta.3 --tag=beta --contents=dist `, { + importMeta: import.meta, booleanDefault: undefined, flags: { anyBranch: { - type: 'boolean' + type: 'boolean', }, branch: { - type: 'string' + type: 'string', }, cleanup: { - type: 'boolean' + type: 'boolean', }, tests: { - type: 'boolean' + type: 'boolean', }, yolo: { - type: 'boolean' + type: 'boolean', }, publish: { - type: 'boolean' + type: 'boolean', }, releaseDraft: { - type: 'boolean' + type: 'boolean', }, releaseDraftOnly: { - type: 'boolean' + type: 'boolean', }, tag: { - type: 'string' + type: 'string', }, yarn: { - type: 'boolean' + type: 'boolean', }, contents: { - type: 'string' + type: 'string', }, preview: { - type: 'boolean' + type: 'boolean', }, testScript: { - type: 'string' + type: 'string', }, '2fa': { - type: 'boolean' + type: 'boolean', }, message: { - type: 'string' - } - } + type: 'string', + }, + }, }); updateNotifier({pkg: cli.pkg}).notify(); -(async () => { - const {pkg, pkgPath} = util.readPkg(); +try { + const {pkg, pkgPath} = await util.readPkg(); const defaultFlags = { cleanup: true, @@ -106,7 +107,7 @@ updateNotifier({pkg: cli.pkg}).notify(); publish: true, releaseDraft: true, yarn: hasYarn(), - '2fa': true + '2fa': true, }; const localConfig = await config(); @@ -114,7 +115,7 @@ updateNotifier({pkg: cli.pkg}).notify(); const flags = { ...defaultFlags, ...localConfig, - ...cli.flags + ...cli.flags, }; // Workaround for unintended auto-casing behavior from `meow`. @@ -126,7 +127,7 @@ updateNotifier({pkg: cli.pkg}).notify(); const availability = flags.publish ? await isPackageNameAvailable(pkg) : { isAvailable: false, - isUnknown: false + isUnknown: false, }; // Use current (latest) version when 'releaseDraftOnly', otherwise use the first argument. @@ -138,22 +139,22 @@ updateNotifier({pkg: cli.pkg}).notify(); availability, version, runPublish, - branch + branch, }, {pkg, pkgPath}); if (!options.confirm) { - process.exit(0); + gracefulExit(); } console.log(); // Prints a newline for readability const newPkg = await np(options.version, options); if (options.preview || options.releaseDraftOnly) { - return; + gracefulExit(); } console.log(`\n ${newPkg.name} ${newPkg.version} published πŸŽ‰`); -})().catch(error => { +} catch (error) { console.error(`\n${logSymbols.error} ${error.message}`); - process.exit(1); -}); + gracefulExit(1); +} diff --git a/source/cli.js b/source/cli.js index f79dcc86..8c71be61 100755 --- a/source/cli.js +++ b/source/cli.js @@ -1,9 +1,10 @@ #!/usr/bin/env node -'use strict'; -const {debuglog} = require('util'); -const importLocal = require('import-local'); -const isInstalledGlobally = require('is-installed-globally'); +import {fileURLToPath} from 'node:url'; +import {debuglog} from 'node:util'; +import importLocal from 'import-local'; +import isInstalledGlobally from 'is-installed-globally'; +const __filename = fileURLToPath(import.meta.url); const log = debuglog('np'); // Prefer the local installation @@ -12,6 +13,5 @@ if (!importLocal(__filename)) { log('Using global install of np.'); } - // eslint-disable-next-line import/no-unassigned-import - require('./cli-implementation'); + await import('./cli-implementation.js'); } diff --git a/source/config.js b/source/config.js index b88fcfd3..cd78f7b8 100644 --- a/source/config.js +++ b/source/config.js @@ -1,21 +1,32 @@ -'use strict'; -const os = require('os'); -const isInstalledGlobally = require('is-installed-globally'); -const pkgDir = require('pkg-dir'); -const {cosmiconfig} = require('cosmiconfig'); +import os from 'node:os'; +import isInstalledGlobally from 'is-installed-globally'; +import {packageDirectory} from 'pkg-dir'; +import {cosmiconfig} from 'cosmiconfig'; -module.exports = async () => { - const searchDir = isInstalledGlobally ? os.homedir() : await pkgDir(); - const searchPlaces = ['.np-config.json', '.np-config.js', '.np-config.cjs']; +// TODO: remove when cosmiconfig/cosmiconfig#283 lands +const loadESM = async filepath => { + const module = await import(filepath); + return module.default ?? module; +}; + +const getConfig = async () => { + const searchDir = isInstalledGlobally ? os.homedir() : await packageDirectory(); + const searchPlaces = ['.np-config.json', '.np-config.js', '.np-config.cjs', '.np-config.mjs']; if (!isInstalledGlobally) { searchPlaces.push('package.json'); } const explorer = cosmiconfig('np', { searchPlaces, - stopDir: searchDir + stopDir: searchDir, + loaders: { + '.js': loadESM, + '.mjs': loadESM, + }, }); const {config} = (await explorer.search(searchDir)) || {}; return config; }; + +export default getConfig; diff --git a/source/git-tasks.js b/source/git-tasks.js index 8f7376f6..8106685a 100644 --- a/source/git-tasks.js +++ b/source/git-tasks.js @@ -1,21 +1,20 @@ -'use strict'; -const Listr = require('listr'); -const git = require('./git-util'); +import Listr from 'listr'; +import * as git from './git-util.js'; -module.exports = options => { +const gitTasks = options => { const tasks = [ { title: 'Check current branch', - task: () => git.verifyCurrentBranchIsReleaseBranch(options.branch) + task: () => git.verifyCurrentBranchIsReleaseBranch(options.branch), }, { title: 'Check local working tree', - task: () => git.verifyWorkingTreeIsClean() + task: () => git.verifyWorkingTreeIsClean(), }, { title: 'Check remote history', - task: () => git.verifyRemoteHistoryIsClean() - } + task: () => git.verifyRemoteHistoryIsClean(), + }, ]; if (options.anyBranch) { @@ -24,3 +23,5 @@ module.exports = options => { return new Listr(tasks); }; + +export default gitTasks; diff --git a/source/git-util.js b/source/git-util.js index 693c88dd..917655ea 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -1,24 +1,23 @@ -'use strict'; -const path = require('path'); -const execa = require('execa'); -const escapeStringRegexp = require('escape-string-regexp'); -const ignoreWalker = require('ignore-walk'); -const pkgDir = require('pkg-dir'); -const {verifyRequirementSatisfied} = require('./version'); - -exports.latestTag = async () => { +import path from 'node:path'; +import {execa} from 'execa'; +import escapeStringRegexp from 'escape-string-regexp'; +import ignoreWalker from 'ignore-walk'; +import {packageDirectorySync} from 'pkg-dir'; +import Version from './version.js'; + +export const latestTag = async () => { const {stdout} = await execa('git', ['describe', '--abbrev=0', '--tags']); return stdout; }; -exports.root = async () => { +export const root = async () => { const {stdout} = await execa('git', ['rev-parse', '--show-toplevel']); return stdout; }; -exports.newFilesSinceLastRelease = async () => { +export const newFilesSinceLastRelease = async () => { try { - const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await this.latestTag(), 'HEAD']); + const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await latestTag(), 'HEAD']); if (stdout.trim().length === 0) { return []; } @@ -28,15 +27,15 @@ exports.newFilesSinceLastRelease = async () => { } catch { // Get all files under version control return ignoreWalker({ - path: pkgDir.sync(), - ignoreFiles: ['.gitignore'] + path: packageDirectorySync(), + ignoreFiles: ['.gitignore'], }); } }; -exports.readFileFromLastRelease = async file => { - const filePathFromRoot = path.relative(await exports.root(), file); - const {stdout: oldFile} = await execa('git', ['show', `${await this.latestTag()}:${filePathFromRoot}`]); +export const readFileFromLastRelease = async file => { + const filePathFromRoot = path.relative(await root(), file); + const {stdout: oldFile} = await execa('git', ['show', `${await latestTag()}:${filePathFromRoot}`]); return oldFile; }; @@ -45,8 +44,8 @@ const firstCommit = async () => { return stdout; }; -exports.previousTagOrFirstCommit = async () => { - const tags = await exports.tagList(); +export const previousTagOrFirstCommit = async () => { + const tags = await tagList(); if (tags.length === 0) { return; @@ -58,7 +57,7 @@ exports.previousTagOrFirstCommit = async () => { try { // Return the tag before the latest one. - const latest = await exports.latestTag(); + const latest = await latestTag(); const index = tags.indexOf(latest); return tags[index - 1]; } catch { @@ -67,11 +66,11 @@ exports.previousTagOrFirstCommit = async () => { } }; -exports.latestTagOrFirstCommit = async () => { +export const latestTagOrFirstCommit = async () => { let latest; try { // In case a previous tag exists, we use it to compare the current repo status to. - latest = await exports.latestTag(); + latest = await latestTag(); } catch { // Otherwise, we fallback to using the first commit for comparison. latest = await firstCommit(); @@ -80,32 +79,32 @@ exports.latestTagOrFirstCommit = async () => { return latest; }; -exports.hasUpstream = async () => { - const escapedCurrentBranch = escapeStringRegexp(await exports.currentBranch()); +export const hasUpstream = async () => { + const escapedCurrentBranch = escapeStringRegexp(await getCurrentBranch()); const {stdout} = await execa('git', ['status', '--short', '--branch', '--porcelain']); return new RegExp(String.raw`^## ${escapedCurrentBranch}\.\.\..+\/${escapedCurrentBranch}`).test(stdout); }; -exports.currentBranch = async () => { +export const getCurrentBranch = async () => { const {stdout} = await execa('git', ['symbolic-ref', '--short', 'HEAD']); return stdout; }; -exports.verifyCurrentBranchIsReleaseBranch = async releaseBranch => { - const currentBranch = await exports.currentBranch(); +export const verifyCurrentBranchIsReleaseBranch = async releaseBranch => { + const currentBranch = await getCurrentBranch(); if (currentBranch !== releaseBranch) { throw new Error(`Not on \`${releaseBranch}\` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.`); } }; -exports.tagList = async () => { +export const tagList = async () => { // Returns the list of tags, sorted by creation date in ascending order. const {stdout} = await execa('git', ['tag', '--sort=creatordate']); return stdout.split('\n'); }; -exports.isHeadDetached = async () => { +export const isHeadDetached = async () => { try { // Command will fail with code 1 if the HEAD is detached. await execa('git', ['symbolic-ref', '--quiet', 'HEAD']); @@ -115,7 +114,7 @@ exports.isHeadDetached = async () => { } }; -exports.isWorkingTreeClean = async () => { +export const isWorkingTreeClean = async () => { try { const {stdout: status} = await execa('git', ['status', '--porcelain']); if (status !== '') { @@ -128,13 +127,13 @@ exports.isWorkingTreeClean = async () => { } }; -exports.verifyWorkingTreeIsClean = async () => { - if (!(await exports.isWorkingTreeClean())) { +export const verifyWorkingTreeIsClean = async () => { + if (!(await isWorkingTreeClean())) { throw new Error('Unclean working tree. Commit or stash changes first.'); } }; -exports.isRemoteHistoryClean = async () => { +export const isRemoteHistoryClean = async () => { let history; try { // Gracefully handle no remote set up. const {stdout} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); @@ -148,13 +147,13 @@ exports.isRemoteHistoryClean = async () => { return true; }; -exports.verifyRemoteHistoryIsClean = async () => { - if (!(await exports.isRemoteHistoryClean())) { +export const verifyRemoteHistoryIsClean = async () => { + if (!(await isRemoteHistoryClean())) { throw new Error('Remote history differs. Please pull changes.'); } }; -exports.verifyRemoteIsValid = async () => { +export const verifyRemoteIsValid = async () => { try { await execa('git', ['ls-remote', 'origin', 'HEAD']); } catch (error) { @@ -162,11 +161,11 @@ exports.verifyRemoteIsValid = async () => { } }; -exports.fetch = async () => { +export const fetch = async () => { await execa('git', ['fetch']); }; -exports.tagExistsOnRemote = async tagName => { +export const tagExistsOnRemote = async tagName => { try { const {stdout: revInfo} = await execa('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tagName}`]); @@ -192,7 +191,7 @@ async function hasLocalBranch(branch) { 'show-ref', '--verify', '--quiet', - `refs/heads/${branch}` + `refs/heads/${branch}`, ]); return true; } catch { @@ -200,7 +199,7 @@ async function hasLocalBranch(branch) { } } -exports.defaultBranch = async () => { +export const defaultBranch = async () => { for (const branch of ['main', 'master', 'gh-pages']) { // eslint-disable-next-line no-await-in-loop if (await hasLocalBranch(branch)) { @@ -209,24 +208,24 @@ exports.defaultBranch = async () => { } throw new Error( - 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.' + 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.', ); }; -exports.verifyTagDoesNotExistOnRemote = async tagName => { - if (await exports.tagExistsOnRemote(tagName)) { +export const verifyTagDoesNotExistOnRemote = async tagName => { + if (await tagExistsOnRemote(tagName)) { throw new Error(`Git tag \`${tagName}\` already exists.`); } }; -exports.commitLogFromRevision = async revision => { +export const commitLogFromRevision = async revision => { const {stdout} = await execa('git', ['log', '--format=%s %h', `${revision}..HEAD`]); return stdout; }; -exports.pushGraceful = async remoteIsOnGitHub => { +export const pushGraceful = async remoteIsOnGitHub => { try { - await exports.push(); + await push(); } catch (error) { if (remoteIsOnGitHub && error.stderr && error.stderr.includes('GH006')) { // Try to push tags only, when commits can't be pushed due to branch protection @@ -238,15 +237,15 @@ exports.pushGraceful = async remoteIsOnGitHub => { } }; -exports.push = async () => { +export const push = async () => { await execa('git', ['push', '--follow-tags']); }; -exports.deleteTag = async tagName => { +export const deleteTag = async tagName => { await execa('git', ['tag', '--delete', tagName]); }; -exports.removeLastCommit = async () => { +export const removeLastCommit = async () => { await execa('git', ['reset', '--hard', 'HEAD~1']); }; @@ -256,13 +255,13 @@ const gitVersion = async () => { return match && match.groups.version; }; -exports.verifyRecentGitVersion = async () => { +export const verifyRecentGitVersion = async () => { const installedVersion = await gitVersion(); - verifyRequirementSatisfied('git', installedVersion); + Version.verifyRequirementSatisfied('git', installedVersion); }; -exports.checkIfFileGitIgnored = async pathToFile => { +export const checkIfFileGitIgnored = async pathToFile => { try { const {stdout} = await execa('git', ['check-ignore', pathToFile]); return Boolean(stdout); diff --git a/source/index.js b/source/index.js index 7d25820c..a3663b01 100644 --- a/source/index.js +++ b/source/index.js @@ -1,43 +1,34 @@ -'use strict'; -require('any-observable/register/rxjs-all'); -const fs = require('fs'); -const path = require('path'); -const execa = require('execa'); -const del = require('del'); -const Listr = require('listr'); -const split = require('split'); -const {merge, throwError} = require('rxjs'); -const {catchError, filter, finalize} = require('rxjs/operators'); -const streamToObservable = require('@samverschueren/stream-to-observable'); -const readPkgUp = require('read-pkg-up'); -const hasYarn = require('has-yarn'); -const pkgDir = require('pkg-dir'); -const hostedGitInfo = require('hosted-git-info'); -const onetime = require('onetime'); -const exitHook = require('async-exit-hook'); -const logSymbols = require('log-symbols'); -const prerequisiteTasks = require('./prerequisite-tasks'); -const gitTasks = require('./git-tasks'); -const publish = require('./npm/publish'); -const enable2fa = require('./npm/enable-2fa'); -const npm = require('./npm/util'); -const releaseTaskHelper = require('./release-task-helper'); -const util = require('./util'); -const git = require('./git-util'); +import fs from 'node:fs'; +import path from 'node:path'; +import {execa} from 'execa'; +import {deleteAsync} from 'del'; +import Listr from 'listr'; +import {merge, throwError, catchError, filter, finalize} from 'rxjs'; +import {readPackageUp} from 'read-pkg-up'; +import hasYarn from 'has-yarn'; +import {packageDirectorySync} from 'pkg-dir'; +import hostedGitInfo from 'hosted-git-info'; +import onetime from 'onetime'; +import {asyncExitHook} from 'exit-hook'; +import logSymbols from 'log-symbols'; +import prerequisiteTasks from './prerequisite-tasks.js'; +import gitTasks from './git-tasks.js'; +import publish from './npm/publish.js'; +import enable2fa from './npm/enable-2fa.js'; +import * as npm from './npm/util.js'; +import releaseTaskHelper from './release-task-helper.js'; +import * as util from './util.js'; +import * as git from './git-util.js'; const exec = (cmd, args) => { // Use `Observable` support if merged https://github.com/sindresorhus/execa/pull/26 const cp = execa(cmd, args); - return merge( - streamToObservable(cp.stdout.pipe(split())), - streamToObservable(cp.stderr.pipe(split())), - cp - ).pipe(filter(Boolean)); + return merge(cp.stdout, cp.stderr, cp).pipe(filter(Boolean)); }; -// eslint-disable-next-line default-param-last -module.exports = async (input = 'patch', options) => { +// eslint-disable-next-line complexity +const np = async (input = 'patch', options) => { if (!hasYarn() && options.yarn) { throw new Error('Could not use Yarn without yarn.lock file'); } @@ -47,14 +38,14 @@ module.exports = async (input = 'patch', options) => { options.cleanup = false; } - const {pkg} = util.readPkg(options.contents); + const {pkg} = await util.readPkg(options.contents); const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; const pkgManager = options.yarn === true ? 'yarn' : 'npm'; const pkgManagerName = options.yarn === true ? 'Yarn' : 'npm'; - const rootDir = pkgDir.sync(); + const rootDir = packageDirectorySync(); const hasLockFile = fs.existsSync(path.resolve(rootDir, options.yarn ? 'yarn.lock' : 'package-lock.json')) || fs.existsSync(path.resolve(rootDir, 'npm-shrinkwrap.json')); - const isOnGitHub = options.repoUrl && (hostedGitInfo.fromUrl(options.repoUrl) || {}).type === 'github'; + const isOnGitHub = options.repoUrl && hostedGitInfo.fromUrl(options.repoUrl)?.type === 'github'; const testScript = options.testScript || 'test'; const testCommand = options.testScript ? ['run', testScript] : [testScript]; @@ -75,8 +66,8 @@ module.exports = async (input = 'patch', options) => { const versionInLatestTag = latestTag.slice(tagVersionPrefix.length); try { - if (versionInLatestTag === util.readPkg().pkg.version && - versionInLatestTag !== pkg.version) { // Verify that the package's version has been bumped before deleting the last tag and commit. + // Verify that the package's version has been bumped before deleting the last tag and commit. + if (versionInLatestTag === util.readPkg().version && versionInLatestTag !== pkg.version) { await git.deleteTag(latestTag); await git.removeLastCommit(); } @@ -87,35 +78,31 @@ module.exports = async (input = 'patch', options) => { } }); - // The default parameter is a workaround for https://github.com/Tapppi/async-exit-hook/issues/9 - exitHook((callback = () => {}) => { - if (options.preview) { - callback(); - } else if (publishStatus === 'FAILED') { - (async () => { - await rollback(); - callback(); - })(); - } else if (publishStatus === 'SUCCESS') { - callback(); + asyncExitHook(async () => { + if (options.preview || publishStatus === 'SUCCESS') { + return; + } + + if (publishStatus === 'FAILED') { + await rollback(); } else { console.log('\nAborted!'); - callback(); } - }); + }, {minimumWait: 2000}); const tasks = new Listr([ { title: 'Prerequisite check', enabled: () => options.runPublish, - task: () => prerequisiteTasks(input, pkg, options) + task: () => prerequisiteTasks(input, pkg, options), }, { title: 'Git', - task: () => gitTasks(options) - } + task: () => gitTasks(options), + }, ], { - showSubtasks: false + showSubtasks: false, + renderer: options.renderer ?? 'default', }); if (runCleanup) { @@ -123,13 +110,13 @@ module.exports = async (input = 'patch', options) => { { title: 'Cleanup', enabled: () => !hasLockFile, - task: () => del('node_modules') + task: () => deleteAsync('node_modules'), }, { title: 'Installing dependencies using Yarn', enabled: () => options.yarn === true, - task: () => { - return exec('yarn', ['install', '--frozen-lockfile', '--production=false']).pipe( + task: () => ( + exec('yarn', ['install', '--frozen-lockfile', '--production=false']).pipe( catchError(async error => { if ((!error.stderr.startsWith('error Your lockfile needs to be updated'))) { return; @@ -140,18 +127,18 @@ module.exports = async (input = 'patch', options) => { } throw new Error('yarn.lock file is outdated. Run yarn, commit the updated lockfile and try again.'); - }) - ); - } + }), + ) + ), }, { title: 'Installing dependencies using npm', enabled: () => options.yarn === false, - task: () => { + task() { const args = hasLockFile ? ['ci'] : ['install', '--no-package-lock', '--no-production']; return exec('npm', [...args, '--engine-strict']); - } - } + }, + }, ]); } @@ -160,7 +147,7 @@ module.exports = async (input = 'patch', options) => { { title: 'Running tests using npm', enabled: () => options.yarn === false, - task: () => exec('npm', testCommand) + task: () => exec('npm', testCommand), }, { title: 'Running tests using Yarn', @@ -171,10 +158,10 @@ module.exports = async (input = 'patch', options) => { return []; } - return throwError(error); - }) - ) - } + return throwError(() => error); + }), + ), + }, ]); } @@ -182,7 +169,7 @@ module.exports = async (input = 'patch', options) => { { title: 'Bumping version using Yarn', enabled: () => options.yarn === true, - skip: () => { + skip() { if (options.preview) { let previewText = `[Preview] Command not executed: yarn version --new-version ${input}`; @@ -193,7 +180,7 @@ module.exports = async (input = 'patch', options) => { return `${previewText}.`; } }, - task: () => { + task() { const args = ['version', '--new-version', input]; if (options.message) { @@ -201,12 +188,12 @@ module.exports = async (input = 'patch', options) => { } return exec('yarn', args); - } + }, }, { title: 'Bumping version using npm', enabled: () => options.yarn === false, - skip: () => { + skip() { if (options.preview) { let previewText = `[Preview] Command not executed: npm version ${input}`; @@ -217,7 +204,7 @@ module.exports = async (input = 'patch', options) => { return `${previewText}.`; } }, - task: () => { + task() { const args = ['version', input]; if (options.message) { @@ -225,21 +212,21 @@ module.exports = async (input = 'patch', options) => { } return exec('npm', args); - } - } + }, + }, ]); if (options.runPublish) { tasks.add([ { title: `Publishing package using ${pkgManagerName}`, - skip: () => { + skip() { if (options.preview) { const args = publish.getPackagePublishArguments(options); return `[Preview] Command not executed: ${pkgManager} ${args.join(' ')}.`; } }, - task: (context, task) => { + task(context, task) { let hasError = false; return publish(context, pkgManager, task, options) @@ -251,10 +238,10 @@ module.exports = async (input = 'patch', options) => { }), finalize(() => { publishStatus = hasError ? 'FAILED' : 'SUCCESS'; - }) + }), ); - } - } + }, + }, ]); const isExternalRegistry = npm.isExternalRegistry(pkg); @@ -262,14 +249,14 @@ module.exports = async (input = 'patch', options) => { tasks.add([ { title: 'Enabling two-factor authentication', - skip: () => { + skip() { if (options.preview) { const args = enable2fa.getEnable2faArgs(pkg.name, options); return `[Preview] Command not executed: npm ${args.join(' ')}.`; } }, - task: (context, task) => enable2fa(task, pkg.name, {otp: context.otp}) - } + task: (context, task) => enable2fa(task, pkg.name, {otp: context.otp}), + }, ]); } } else { @@ -278,7 +265,7 @@ module.exports = async (input = 'patch', options) => { tasks.add({ title: 'Pushing tags', - skip: async () => { + async skip() { if (!(await git.hasUpstream())) { return 'Upstream branch not found; not pushing.'; } @@ -291,21 +278,21 @@ module.exports = async (input = 'patch', options) => { return 'Couldn\'t publish package to npm; not pushing.'; } }, - task: async () => { + async task() { pushedObjects = await git.pushGraceful(isOnGitHub); - } + }, }); if (options.releaseDraft) { tasks.add({ title: 'Creating release draft on GitHub', enabled: () => isOnGitHub === true, - skip: () => { + skip() { if (options.preview) { return '[Preview] GitHub Releases draft will not be opened in preview mode.'; } }, - task: () => releaseTaskHelper(options, pkg) + task: () => releaseTaskHelper(options, pkg), }); } @@ -315,6 +302,8 @@ module.exports = async (input = 'patch', options) => { console.error(`\n${logSymbols.error} ${pushedObjects.reason}`); } - const {packageJson: newPkg} = await readPkgUp(); + const {packageJson: newPkg} = await readPackageUp(); return newPkg; }; + +export default np; diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index a9e2ef34..03c29ff5 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -1,10 +1,8 @@ -'use strict'; -const execa = require('execa'); -const {from} = require('rxjs'); -const {catchError} = require('rxjs/operators'); -const handleNpmError = require('./handle-npm-error'); +import {execa} from 'execa'; +import {from, catchError} from 'rxjs'; +import handleNpmError from './handle-npm-error.js'; -const getEnable2faArgs = (packageName, options) => { +export const getEnable2faArgs = (packageName, options) => { const args = ['access', '2fa-required', packageName]; if (options && options.otp) { @@ -16,9 +14,10 @@ const getEnable2faArgs = (packageName, options) => { const enable2fa = (packageName, options) => execa('npm', getEnable2faArgs(packageName, options)); -module.exports = (task, packageName, options) => +const tryEnable2fa = (task, packageName, options) => { from(enable2fa(packageName, options)).pipe( - catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp}))) + catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp}))), ); +}; -module.exports.getEnable2faArgs = getEnable2faArgs; +export default tryEnable2fa; diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index fa191c51..7ec39c88 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -1,7 +1,6 @@ -const listrInput = require('listr-input'); -const chalk = require('chalk'); -const {throwError} = require('rxjs'); -const {catchError} = require('rxjs/operators'); +import listrInput from 'listr-input'; +import chalk from 'chalk'; +import {throwError, catchError} from 'rxjs'; const handleNpmError = (error, task, message, executor) => { if (typeof message === 'function') { @@ -15,13 +14,13 @@ const handleNpmError = (error, task, message, executor) => { task.title = `${title} ${chalk.yellow('(waiting for input…)')}`; return listrInput('Enter OTP:', { - done: otp => { + done(otp) { task.title = title; return executor(otp); }, - autoSubmit: value => value.length === 6 + autoSubmit: value => value.length === 6, }).pipe( - catchError(error => handleNpmError(error, task, 'OTP was incorrect, try again:', executor)) + catchError(error => handleNpmError(error, task, 'OTP was incorrect, try again:', executor)), ); } @@ -31,7 +30,7 @@ const handleNpmError = (error, task, message, executor) => { throw new Error('You cannot publish a privately scoped package without a paid plan. Did you mean to publish publicly?'); } - return throwError(error); + return throwError(() => error); }; -module.exports = handleNpmError; +export default handleNpmError; diff --git a/source/npm/publish.js b/source/npm/publish.js index 0a4ed4ee..85a73ddb 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -1,10 +1,8 @@ -'use strict'; -const execa = require('execa'); -const {from} = require('rxjs'); -const {catchError} = require('rxjs/operators'); -const handleNpmError = require('./handle-npm-error'); +import {execa} from 'execa'; +import {from, catchError} from 'rxjs'; +import handleNpmError from './handle-npm-error.js'; -const getPackagePublishArguments = options => { +export const getPackagePublishArguments = options => { const args = ['publish']; if (options.contents) { @@ -28,13 +26,14 @@ const getPackagePublishArguments = options => { const pkgPublish = (pkgManager, options) => execa(pkgManager, getPackagePublishArguments(options)); -module.exports = (context, pkgManager, task, options) => +const publish = (context, pkgManager, task, options) => { from(pkgPublish(pkgManager, options)).pipe( catchError(error => handleNpmError(error, task, otp => { context.otp = otp; return pkgPublish(pkgManager, {...options, otp}); - })) + })), ); +}; -module.exports.getPackagePublishArguments = getPackagePublishArguments; +export default publish; diff --git a/source/npm/util.js b/source/npm/util.js index 2d594761..0d6582a5 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -1,16 +1,15 @@ -'use strict'; -const fs = require('fs'); -const path = require('path'); -const execa = require('execa'); -const pTimeout = require('p-timeout'); -const {default: ow} = require('ow'); -const npmName = require('npm-name'); -const chalk = require('chalk'); -const pkgDir = require('pkg-dir'); -const ignoreWalker = require('ignore-walk'); -const minimatch = require('minimatch'); -const {verifyRequirementSatisfied} = require('../version'); -const semver = require('semver'); +import fs from 'node:fs'; +import path from 'node:path'; +import {execa} from 'execa'; +import pTimeout from 'p-timeout'; +import ow from 'ow'; +import npmName from 'npm-name'; +import chalk from 'chalk'; +import {packageDirectorySync} from 'pkg-dir'; +import ignoreWalker from 'ignore-walk'; +import minimatch from 'minimatch'; +import semver from 'semver'; +import Version from '../version.js'; // According to https://docs.npmjs.com/files/package.json#files // npm's default behavior is to ignore these files. @@ -32,10 +31,10 @@ const filesIgnoredByDefault = [ 'npm-debug.log', 'package-lock.json', '.git/**/*', - '.git' + '.git', ]; -exports.checkConnection = () => pTimeout( +export const checkConnection = () => pTimeout( (async () => { try { await execa('npm', ['ping']); @@ -43,12 +42,13 @@ exports.checkConnection = () => pTimeout( } catch { throw new Error('Connection to npm registry failed'); } - })(), - 15000, - 'Connection to npm registry timed out' + })(), { + milliseconds: 15_000, + message: 'Connection to npm registry timed out', + }, ); -exports.username = async ({externalRegistry}) => { +export const username = async ({externalRegistry}) => { const args = ['whoami']; if (externalRegistry) { @@ -59,19 +59,19 @@ exports.username = async ({externalRegistry}) => { const {stdout} = await execa('npm', args); return stdout; } catch (error) { - throw new Error(/ENEEDAUTH/.test(error.stderr) ? - 'You must be logged in. Use `npm login` and try again.' : - 'Authentication error. Use `npm whoami` to troubleshoot.'); + throw new Error(/ENEEDAUTH/.test(error.stderr) + ? 'You must be logged in. Use `npm login` and try again.' + : 'Authentication error. Use `npm whoami` to troubleshoot.'); } }; -exports.collaborators = async pkg => { +export const collaborators = async pkg => { const packageName = pkg.name; ow(packageName, ow.string); - const npmVersion = await exports.version(); + const npmVersion = await version(); const args = semver.satisfies(npmVersion, '>=9.0.0') ? ['access', 'list', 'collaborators', packageName, '--json'] : ['access', 'ls-collaborators', packageName]; - if (exports.isExternalRegistry(pkg)) { + if (isExternalRegistry(pkg)) { args.push('--registry', pkg.publishConfig.registry); } @@ -88,7 +88,7 @@ exports.collaborators = async pkg => { } }; -exports.prereleaseTags = async packageName => { +export const prereleaseTags = async packageName => { ow(packageName, ow.string); let tags = []; @@ -120,16 +120,16 @@ exports.prereleaseTags = async packageName => { return tags; }; -exports.isPackageNameAvailable = async pkg => { +export const isPackageNameAvailable = async pkg => { const args = [pkg.name]; const availability = { isAvailable: false, - isUnknown: false + isUnknown: false, }; - if (exports.isExternalRegistry(pkg)) { + if (isExternalRegistry(pkg)) { args.push({ - registryUrl: pkg.publishConfig.registry + registryUrl: pkg.publishConfig.registry, }); } @@ -142,19 +142,19 @@ exports.isPackageNameAvailable = async pkg => { return availability; }; -exports.isExternalRegistry = pkg => typeof pkg.publishConfig === 'object' && typeof pkg.publishConfig.registry === 'string'; +export const isExternalRegistry = pkg => typeof pkg.publishConfig === 'object' && typeof pkg.publishConfig.registry === 'string'; -exports.version = async () => { +export const version = async () => { const {stdout} = await execa('npm', ['--version']); return stdout; }; -exports.verifyRecentNpmVersion = async () => { - const npmVersion = await exports.version(); - verifyRequirementSatisfied('npm', npmVersion); +export const verifyRecentNpmVersion = async () => { + const npmVersion = await version(); + Version.verifyRequirementSatisfied('npm', npmVersion); }; -exports.checkIgnoreStrategy = ({files}) => { +export const checkIgnoreStrategy = ({files}) => { if (!files && !npmignoreExistsInPackageRootDir()) { console.log(` \n${chalk.bold.yellow('Warning:')} No ${chalk.bold.cyan('files')} field specified in ${chalk.bold.magenta('package.json')} nor is a ${chalk.bold.magenta('.npmignore')} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. @@ -163,7 +163,7 @@ exports.checkIgnoreStrategy = ({files}) => { }; function npmignoreExistsInPackageRootDir() { - const rootDir = pkgDir.sync(); + const rootDir = packageDirectorySync(); return fs.existsSync(path.resolve(rootDir, '.npmignore')); } @@ -172,10 +172,11 @@ function excludeGitAndNodeModulesPaths(singlePath) { } async function getFilesIgnoredByDotnpmignore(pkg, fileList) { - const allowList = (await ignoreWalker({ - path: pkgDir.sync(), - ignoreFiles: ['.npmignore'] - })).filter(singlePath => excludeGitAndNodeModulesPaths(singlePath)); + let allowList = await ignoreWalker({ + path: packageDirectorySync(), + ignoreFiles: ['.npmignore'], + }); + allowList = allowList.filter(singlePath => excludeGitAndNodeModulesPaths(singlePath)); return fileList.filter(minimatch.filter(getIgnoredFilesGlob(allowList, pkg.directories), {matchBase: true, dot: true})); } @@ -185,20 +186,20 @@ function filterFileList(globArray, fileList) { } const globString = globArray.length > 1 ? `{${globArray.filter(singlePath => excludeGitAndNodeModulesPaths(singlePath))}}` : globArray[0]; - return fileList.filter(minimatch.filter(globString, {matchBase: true, dot: true})); // eslint-disable-line unicorn/no-fn-reference-in-iterator + return fileList.filter(minimatch.filter(globString, {matchBase: true, dot: true})); // eslint-disable-line unicorn/no-array-callback-reference, unicorn/no-array-method-this-argument } async function getFilesIncludedByDotnpmignore(pkg, fileList) { const allowList = await ignoreWalker({ - path: pkgDir.sync(), - ignoreFiles: ['.npmignore'] + path: packageDirectorySync(), + ignoreFiles: ['.npmignore'], }); return filterFileList(allowList, fileList); } function getFilesNotIncludedInFilesProperty(pkg, fileList) { const globArrayForFilesAndDirectories = [...pkg.files]; - const rootDir = pkgDir.sync(); + const rootDir = packageDirectorySync(); for (const glob of pkg.files) { try { if (fs.statSync(path.resolve(rootDir, glob)).isDirectory()) { @@ -213,7 +214,7 @@ function getFilesNotIncludedInFilesProperty(pkg, fileList) { function getFilesIncludedInFilesProperty(pkg, fileList) { const globArrayForFilesAndDirectories = [...pkg.files]; - const rootDir = pkgDir.sync(); + const rootDir = packageDirectorySync(); for (const glob of pkg.files) { try { if (fs.statSync(path.resolve(rootDir, glob)).isDirectory()) { @@ -236,7 +237,7 @@ function getDefaultIncludedFilesGlob(mainFile) { 'HISTORY*', 'LICENSE*', 'LICENCE*', - 'NOTICE*' + 'NOTICE*', ]; if (mainFile) { filesAlwaysIncluded.push(mainFile); @@ -261,7 +262,7 @@ function getIgnoredFilesGlob(globArrayFromFilesProperty, packageDirectories) { } // Get all files which will be ignored by either `.npmignore` or the `files` property in `package.json` (if defined). -exports.getNewAndUnpublishedFiles = async (pkg, newFiles = []) => { +export const getNewAndUnpublishedFiles = async (pkg, newFiles = []) => { if (pkg.files) { return getFilesNotIncludedInFilesProperty(pkg, newFiles); } @@ -273,7 +274,7 @@ exports.getNewAndUnpublishedFiles = async (pkg, newFiles = []) => { return []; }; -exports.getFirstTimePublishedFiles = async (pkg, newFiles = []) => { +export const getFirstTimePublishedFiles = async (pkg, newFiles = []) => { let result; if (pkg.files) { result = getFilesIncludedInFilesProperty(pkg, newFiles); @@ -286,11 +287,10 @@ exports.getFirstTimePublishedFiles = async (pkg, newFiles = []) => { return result.filter(minimatch.filter(`!{${filesIgnoredByDefault}}`, {matchBase: true, dot: true})).filter(minimatch.filter(getDefaultIncludedFilesGlob(pkg.main), {nocase: true, matchBase: true})); }; -exports.getRegistryUrl = async (pkgManager, pkg) => { +export const getRegistryUrl = async (pkgManager, pkg) => { const args = ['config', 'get', 'registry']; - if (exports.isExternalRegistry(pkg)) { - args.push('--registry'); - args.push(pkg.publishConfig.registry); + if (isExternalRegistry(pkg)) { + args.push('--registry', pkg.publishConfig.registry); } const {stdout} = await execa(pkgManager, args); diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 56b12aa7..50006c8d 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -1,12 +1,12 @@ -'use strict'; -const Listr = require('listr'); -const execa = require('execa'); -const version = require('./version'); -const git = require('./git-util'); -const npm = require('./npm/util'); -const {getTagVersionPrefix} = require('./util'); +import process from 'node:process'; +import Listr from 'listr'; +import {execa} from 'execa'; +import Version from './version.js'; +import * as git from './git-util.js'; +import * as npm from './npm/util.js'; +import {getTagVersionPrefix} from './util.js'; -module.exports = (input, pkg, options) => { +const prerequisiteTasks = (input, pkg, options) => { const isExternalRegistry = npm.isExternalRegistry(pkg); let newVersion = null; @@ -14,26 +14,26 @@ module.exports = (input, pkg, options) => { { title: 'Ping npm registry', enabled: () => !pkg.private && !isExternalRegistry, - task: async () => npm.checkConnection() + task: async () => npm.checkConnection(), }, { title: 'Check npm version', - task: async () => npm.verifyRecentNpmVersion() + task: async () => npm.verifyRecentNpmVersion(), }, { title: 'Check yarn version', enabled: () => options.yarn === true, - task: async () => { + async task() { const {stdout: yarnVersion} = await execa('yarn', ['--version']); - version.verifyRequirementSatisfied('yarn', yarnVersion); - } + Version.verifyRequirementSatisfied('yarn', yarnVersion); + }, }, { title: 'Verify user is authenticated', enabled: () => process.env.NODE_ENV !== 'test' && !pkg.private, - task: async () => { + async task() { const username = await npm.username({ - externalRegistry: isExternalRegistry ? pkg.publishConfig.registry : false + externalRegistry: isExternalRegistry ? pkg.publishConfig.registry : false, }); const collaborators = await npm.collaborators(pkg); @@ -46,41 +46,43 @@ module.exports = (input, pkg, options) => { if (!permissions || !permissions.includes('write')) { throw new Error('You do not have write permissions required to publish this package.'); } - } + }, }, { title: 'Check git version', - task: async () => git.verifyRecentGitVersion() + task: async () => git.verifyRecentGitVersion(), }, { title: 'Check git remote', - task: async () => git.verifyRemoteIsValid() + task: async () => git.verifyRemoteIsValid(), }, { title: 'Validate version', - task: () => { - newVersion = version.getAndValidateNewVersionFrom(input, pkg.version); - } + task() { + newVersion = Version.getAndValidateNewVersionFrom(input, pkg.version); + }, }, { title: 'Check for pre-release version', - task: () => { - if (!pkg.private && version(newVersion).isPrerelease() && !options.tag) { + task() { + if (!pkg.private && new Version(newVersion).isPrerelease() && !options.tag) { throw new Error('You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'); } - } + }, }, { title: 'Check git tag existence', - task: async () => { + async task() { await git.fetch(); const tagPrefix = await getTagVersionPrefix(options); await git.verifyTagDoesNotExistOnRemote(`${tagPrefix}${newVersion}`); - } - } + }, + }, ]; return new Listr(tasks); }; + +export default prerequisiteTasks; diff --git a/source/pretty-version-diff.js b/source/pretty-version-diff.js index 15eb60b8..059fa219 100644 --- a/source/pretty-version-diff.js +++ b/source/pretty-version-diff.js @@ -1,9 +1,8 @@ -'use strict'; -const chalk = require('chalk'); -const version = require('./version'); +import chalk from 'chalk'; +import Version from './version.js'; -module.exports = (oldVersion, inc) => { - const newVersion = version(oldVersion).getNewVersionFrom(inc).split('.'); +const prettyVersionDiff = (oldVersion, inc) => { + const newVersion = new Version(oldVersion).getNewVersionFrom(inc).split('.'); oldVersion = oldVersion.split('.'); let firstVersionChange = false; const output = []; @@ -23,3 +22,5 @@ module.exports = (oldVersion, inc) => { return output.join(chalk.reset.dim('.')); }; + +export default prettyVersionDiff; diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 53d93d6f..2e71aca9 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -1,13 +1,12 @@ -'use strict'; -const open = require('open'); -const newGithubReleaseUrl = require('new-github-release-url'); -const {getTagVersionPrefix, getPreReleasePrefix} = require('./util'); -const version = require('./version'); +import open from 'open'; +import newGithubReleaseUrl from 'new-github-release-url'; +import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; +import Version from './version.js'; -module.exports = async (options, pkg) => { - const newVersion = version(pkg.version).getNewVersionFrom(options.version); +const releaseTaskHelper = async (options, pkg) => { + const newVersion = new Version(pkg.version).getNewVersionFrom(options.version); let tag = await getTagVersionPrefix(options) + newVersion; - const isPreRelease = version(options.version).isPrerelease(); + const isPreRelease = new Version(options.version).isPrerelease(); if (isPreRelease) { tag += await getPreReleasePrefix(options); } @@ -16,8 +15,10 @@ module.exports = async (options, pkg) => { repoUrl: options.repoUrl, tag, body: options.releaseNotes(tag), - isPrerelease: isPreRelease + isPrerelease: isPreRelease, }); await open(url); }; + +export default releaseTaskHelper; diff --git a/source/ui.js b/source/ui.js index 247cbd39..3cbfb213 100644 --- a/source/ui.js +++ b/source/ui.js @@ -1,15 +1,14 @@ -'use strict'; -const inquirer = require('inquirer'); -const chalk = require('chalk'); -const githubUrlFromGit = require('github-url-from-git'); -const {htmlEscape} = require('escape-goat'); -const isScoped = require('is-scoped'); -const isInteractive = require('is-interactive'); -const util = require('./util'); -const git = require('./git-util'); -const {prereleaseTags, checkIgnoreStrategy, getRegistryUrl, isExternalRegistry} = require('./npm/util'); -const version = require('./version'); -const prettyVersionDiff = require('./pretty-version-diff'); +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 * as util from './util.js'; +import * as git from './git-util.js'; +import {prereleaseTags, checkIgnoreStrategy, getRegistryUrl, isExternalRegistry} from './npm/util.js'; +import Version from './version.js'; +import prettyVersionDiff from './pretty-version-diff.js'; const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch) => { const revision = fromLatestTag ? await git.latestTagOrFirstCommit() : await git.previousTagOrFirstCommit(); @@ -23,7 +22,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch return { hasCommits: false, hasUnreleasedCommits: false, - releaseNotes: () => {} + releaseNotes() {}, }; } @@ -35,7 +34,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch const splitIndex = commit.lastIndexOf(' '); return { message: commit.slice(0, splitIndex), - id: commit.slice(splitIndex + 1) + id: commit.slice(splitIndex + 1), }; }); @@ -66,7 +65,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch }).join('\n'); const releaseNotes = nextTag => commits.map(commit => - `- ${htmlEscape(commit.message)} ${commit.id}` + `- ${htmlEscape(commit.message)} ${commit.id}`, ).join('\n') + `\n\n${repoUrl}/compare/${revision}...${nextTag}`; const commitRange = util.linkifyCommitRange(repoUrl, commitRangeText); @@ -75,7 +74,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch return { hasCommits: true, hasUnreleasedCommits, - releaseNotes + releaseNotes, }; }; @@ -115,13 +114,14 @@ const checkNewFilesAndDependencies = async (pkg, pkgPath) => { type: 'confirm', name: 'confirm', message: `${messages.join('\n')}\nContinue?`, - default: false + default: false, }]); return answers.confirm; }; -module.exports = async (options, {pkg, pkgPath}) => { +// eslint-disable-next-line complexity +const ui = async (options, {pkg, pkgPath}) => { const oldVersion = pkg.version; const extraBaseUrls = ['gitlab.com']; const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); @@ -136,7 +136,7 @@ module.exports = async (options, {pkg, pkgPath}) => { if (!answerIgnoredFiles) { return { ...options, - confirm: answerIgnoredFiles + confirm: answerIgnoredFiles, }; } } @@ -144,56 +144,115 @@ module.exports = async (options, {pkg, pkgPath}) => { if (options.releaseDraftOnly) { console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); } else { - const newVersion = options.version ? version.getAndValidateNewVersionFrom(options.version, oldVersion) : undefined; + const newVersion = options.version ? Version.getAndValidateNewVersionFrom(options.version, oldVersion) : undefined; const versionText = chalk.dim(`(current: ${oldVersion}${newVersion ? `, next: ${prettyVersionDiff(oldVersion, newVersion)}` : ''}${chalk.dim(')')}`); console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${versionText}\n`); } - const prompts = [ - { + 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, + }; + } + } + + 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) { + const answers = await inquirer.prompt({ + confirm: { + type: 'confirm', + when: isScoped(pkg.name) && options.runPublish, + message: `Failed to check availability of scoped repo name ${chalk.bold.magenta(pkg.name)}. Do you want to try and publish it anyway?`, + default: false, + }, + }); + + if (!answers.confirm) { + return { + ...options, + ...answers, + }; + } + } + + const answers = await inquirer.prompt({ + version: { type: 'list', - name: 'version', message: 'Select semver increment or specify new version', - pageSize: version.SEMVER_INCREMENTS.length + 2, - choices: version.SEMVER_INCREMENTS + pageSize: Version.SEMVER_INCREMENTS.length + 2, + choices: [...Version.SEMVER_INCREMENTS .map(inc => ({ name: `${inc} ${prettyVersionDiff(oldVersion, inc)}`, - value: inc - })) - .concat([ - new inquirer.Separator(), - { - name: 'Other (specify)', - value: null - } - ]), - filter: input => version.isValidInput(input) ? version(oldVersion).getNewVersionFrom(input) : input + value: inc, + })), + new inquirer.Separator(), + { + name: 'Other (specify)', + value: null, + }], + filter: input => Version.isValidInput(input) ? new Version(oldVersion).getNewVersionFrom(input) : input, }, - { + customVersion: { type: 'input', - name: 'customVersion', message: 'Version', when: answers => !answers.version, - filter: input => version.isValidInput(input) ? version(pkg.version).getNewVersionFrom(input) : input, - validate: input => { - if (!version.isValidInput(input)) { + filter: input => Version.isValidInput(input) ? new Version(pkg.version).getNewVersionFrom(input) : input, + validate(input) { + if (!Version.isValidInput(input)) { return 'Please specify a valid semver, for example, `1.2.3`. See https://semver.org'; } - if (version(oldVersion).isLowerThanOrEqualTo(input)) { + if (new Version(oldVersion).isLowerThanOrEqualTo(input)) { return `Version must be greater than ${oldVersion}`; } return true; - } + }, }, - { + tag: { type: 'list', - name: 'tag', message: 'How should this pre-release version be tagged in npm?', - when: answers => options.runPublish && (version.isPrereleaseOrIncrement(answers.customVersion) || version.isPrereleaseOrIncrement(answers.version)) && !options.tag, - choices: async () => { + when: answers => options.runPublish && (Version.isPrereleaseOrIncrement(answers.customVersion) || Version.isPrereleaseOrIncrement(answers.version)) && !options.tag, + async choices() { const existingPrereleaseTags = await prereleaseTags(pkg.name); return [ @@ -201,17 +260,16 @@ module.exports = async (options, {pkg, pkgPath}) => { new inquirer.Separator(), { name: 'Other (specify)', - value: null - } + value: null, + }, ]; - } + }, }, - { + customTag: { type: 'input', - name: 'customTag', message: 'Tag', - when: answers => options.runPublish && (version.isPrereleaseOrIncrement(answers.customVersion) || version.isPrereleaseOrIncrement(answers.version)) && !options.tag && !answers.tag, - validate: input => { + when: answers => options.runPublish && (Version.isPrereleaseOrIncrement(answers.customVersion) || Version.isPrereleaseOrIncrement(answers.version)) && !options.tag && !answers.tag, + validate(input) { if (input.length === 0) { return 'Please specify a tag, for example, `next`.'; } @@ -221,79 +279,15 @@ module.exports = async (options, {pkg, pkgPath}) => { } return true; - } + }, }, - { + publishScoped: { type: 'confirm', - name: 'publishScoped', when: isScoped(pkg.name) && options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !isExternalRegistry(pkg), message: `This scoped repo ${chalk.bold.magenta(pkg.name)} hasn't been published. Do you want to publish it publicly?`, - default: false - } - ]; - - const useLatestTag = !options.releaseDraftOnly; - const {hasCommits, hasUnreleasedCommits, releaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag, releaseBranch); - - if (hasUnreleasedCommits && options.releaseDraftOnly) { - const answers = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - message: 'Unreleased commits found. They won\'t be included in the release draft. Continue?', - default: false - }]); - - if (!answers.confirm) { - return { - ...options, - ...answers - }; - } - } - - if (options.version) { - return { - ...options, - confirm: true, - repoUrl, - releaseNotes - }; - } - - if (!hasCommits) { - const answers = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - message: 'No commits found since previous release, continue?', - default: false - }]); - - if (!answers.confirm) { - return { - ...options, - ...answers - }; - } - } - - if (options.availability.isUnknown) { - const answers = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - when: isScoped(pkg.name) && options.runPublish, - message: `Failed to check availability of scoped repo name ${chalk.bold.magenta(pkg.name)}. Do you want to try and publish it anyway?`, - default: false - }]); - - if (!answers.confirm) { - return { - ...options, - ...answers - }; - } - } - - const answers = await inquirer.prompt(prompts); + default: false, + }, + }); return { ...options, @@ -302,6 +296,8 @@ module.exports = async (options, {pkg, pkgPath}) => { publishScoped: answers.publishScoped, confirm: true, repoUrl, - releaseNotes + releaseNotes, }; }; + +export default ui; diff --git a/source/util.js b/source/util.js index 0b683fc6..b76a4080 100644 --- a/source/util.js +++ b/source/util.js @@ -1,30 +1,28 @@ -'use strict'; -const readPkgUp = require('read-pkg-up'); -const issueRegex = require('issue-regex'); -const terminalLink = require('terminal-link'); -const execa = require('execa'); -const pMemoize = require('p-memoize'); -const {default: ow} = require('ow'); -const pkgDir = require('pkg-dir'); -const chalk = require('chalk'); -const gitUtil = require('./git-util'); -const npmUtil = require('./npm/util'); - -exports.readPkg = packagePath => { - packagePath = packagePath ? pkgDir.sync(packagePath) : pkgDir.sync(); - +import {readPackageUp} from 'read-pkg-up'; +import issueRegex from 'issue-regex'; +import terminalLink from 'terminal-link'; +import {execa} from 'execa'; +import pMemoize from 'p-memoize'; +import ow from 'ow'; +import chalk from 'chalk'; +import {packageDirectory} from 'pkg-dir'; +import * as gitUtil from './git-util.js'; +import * as npmUtil from './npm/util.js'; + +export const readPkg = async packagePath => { + packagePath = packagePath ? await packageDirectory(packagePath) : await packageDirectory(); if (!packagePath) { throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); } - const {packageJson, path} = readPkgUp.sync({ - cwd: packagePath + const {packageJson, path} = await readPackageUp({ + cwd: packagePath, }); return {pkg: packageJson, pkgPath: path}; }; -exports.linkifyIssues = (url, message) => { +export const linkifyIssues = (url, message) => { if (!(url && terminalLink.isSupported)) { return message; } @@ -40,7 +38,7 @@ exports.linkifyIssues = (url, message) => { }); }; -exports.linkifyCommit = (url, commit) => { +export const linkifyCommit = (url, commit) => { if (!(url && terminalLink.isSupported)) { return commit; } @@ -48,7 +46,7 @@ exports.linkifyCommit = (url, commit) => { return terminalLink(commit, `${url}/commit/${commit}`); }; -exports.linkifyCommitRange = (url, commitRange) => { +export const linkifyCommitRange = (url, commitRange) => { if (!(url && terminalLink.isSupported)) { return commitRange; } @@ -56,7 +54,7 @@ exports.linkifyCommitRange = (url, commitRange) => { return terminalLink(commitRange, `${url}/compare/${commitRange}`); }; -exports.getTagVersionPrefix = pMemoize(async options => { +export const getTagVersionPrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); try { @@ -72,14 +70,14 @@ exports.getTagVersionPrefix = pMemoize(async options => { } }); -exports.joinList = list => chalk.reset(list.map(item => `- ${item}`).join('\n')); +export const joinList = list => chalk.reset(list.map(item => `- ${item}`).join('\n')); -exports.getNewFiles = async pkg => { +export const getNewFiles = async pkg => { const listNewFiles = await gitUtil.newFilesSinceLastRelease(); return {unpublished: await npmUtil.getNewAndUnpublishedFiles(pkg, listNewFiles), firstTime: await npmUtil.getFirstTimePublishedFiles(pkg, listNewFiles)}; }; -exports.getNewDependencies = async (newPkg, pkgPath) => { +export const getNewDependencies = async (newPkg, pkgPath) => { let oldPkg = await gitUtil.readFileFromLastRelease(pkgPath); oldPkg = JSON.parse(oldPkg); @@ -94,7 +92,7 @@ exports.getNewDependencies = async (newPkg, pkgPath) => { return newDependencies; }; -exports.getPreReleasePrefix = pMemoize(async options => { +export const getPreReleasePrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); try { diff --git a/source/version.js b/source/version.js index a0504466..56710fac 100644 --- a/source/version.js +++ b/source/version.js @@ -1,7 +1,9 @@ -'use strict'; -const semver = require('semver'); +import semver from 'semver'; +import {readPackageUp} from 'read-pkg-up'; -class Version { +const {packageJson: pkg} = await readPackageUp(); + +export default class Version { constructor(version) { this.version = version; } @@ -11,66 +13,64 @@ class Version { } satisfies(range) { - module.exports.validate(this.version); + Version.validate(this.version); return semver.satisfies(this.version, range, { - includePrerelease: true + includePrerelease: true, }); } getNewVersionFrom(input) { - module.exports.validate(this.version); - if (!module.exports.isValidInput(input)) { - throw new Error(`Version should be either ${module.exports.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`); + Version.validate(this.version); + if (!Version.isValidInput(input)) { + throw new Error(`Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`); } - return module.exports.SEMVER_INCREMENTS.includes(input) ? semver.inc(this.version, input) : input; + return Version.SEMVER_INCREMENTS.includes(input) ? semver.inc(this.version, input) : input; } isGreaterThanOrEqualTo(otherVersion) { - module.exports.validate(this.version); - module.exports.validate(otherVersion); + Version.validate(this.version); + Version.validate(otherVersion); return semver.gte(otherVersion, this.version); } isLowerThanOrEqualTo(otherVersion) { - module.exports.validate(this.version); - module.exports.validate(otherVersion); + Version.validate(this.version); + Version.validate(otherVersion); return semver.lte(otherVersion, this.version); } -} - -module.exports = version => new Version(version); -module.exports.SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; -module.exports.PRERELEASE_VERSIONS = ['prepatch', 'preminor', 'premajor', 'prerelease']; + static SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; + static PRERELEASE_VERSIONS = ['prepatch', 'preminor', 'premajor', 'prerelease']; -module.exports.isPrereleaseOrIncrement = input => module.exports(input).isPrerelease() || module.exports.PRERELEASE_VERSIONS.includes(input); + static isPrereleaseOrIncrement = input => new Version(input).isPrerelease() || Version.PRERELEASE_VERSIONS.includes(input); -const isValidVersion = input => Boolean(semver.valid(input)); + static isValidVersion = input => Boolean(semver.valid(input)); -module.exports.isValidInput = input => module.exports.SEMVER_INCREMENTS.includes(input) || isValidVersion(input); + static isValidInput = input => Version.SEMVER_INCREMENTS.includes(input) || Version.isValidVersion(input); -module.exports.validate = version => { - if (!isValidVersion(version)) { - throw new Error('Version should be a valid semver version.'); + static validate(version) { + if (!Version.isValidVersion(version)) { + throw new Error('Version should be a valid semver version.'); + } } -}; -module.exports.verifyRequirementSatisfied = (dependency, version) => { - const depRange = require('../package.json').engines[dependency]; - if (!module.exports(version).satisfies(depRange)) { - throw new Error(`Please upgrade to ${dependency}${depRange}`); + static verifyRequirementSatisfied(dependency, version) { + const depRange = pkg.engines[dependency]; + if (!new Version(version).satisfies(depRange)) { + throw new Error(`Please upgrade to ${dependency}${depRange}`); + } } -}; -module.exports.getAndValidateNewVersionFrom = (input, version) => { - const newVersion = module.exports(version).getNewVersionFrom(input); + static getAndValidateNewVersionFrom(input, version) { + const newVersion = new Version(version).getNewVersionFrom(input); - if (module.exports(version).isLowerThanOrEqualTo(newVersion)) { - throw new Error(`New version \`${newVersion}\` should be higher than current version \`${version}\``); - } + if (new Version(version).isLowerThanOrEqualTo(newVersion)) { + throw new Error(`New version \`${newVersion}\` should be higher than current version \`${version}\``); + } - return newVersion; -}; + return newVersion; + } +} diff --git a/test/_utils.js b/test/_utils.js new file mode 100644 index 00000000..1f9a47f3 --- /dev/null +++ b/test/_utils.js @@ -0,0 +1,50 @@ +import esmock from 'esmock'; +import {execa} from 'execa'; +import {SilentRenderer} from './fixtures/listr-renderer.js'; + +export const _stubExeca = source => async (t, commands) => esmock(source, {}, { + execa: { + async execa(...args) { + const results = await Promise.all(commands.map(async result => { + const argsMatch = await t.try(tt => { + const [command, ...commandArgs] = result.command.split(' '); + tt.deepEqual(args, [command, commandArgs]); + }); + + if (argsMatch.passed) { + argsMatch.discard(); + + if (!result.exitCode || result.exitCode === 0) { + return result; + } + + throw result; + } + + argsMatch.discard(); + })); + + const result = results.filter(Boolean).at(0); + return result ?? execa(...args); + }, + }, +}); + +export const run = async listr => { + listr.setRenderer(SilentRenderer); + await listr.run(); +}; + +export const assertTaskFailed = (t, taskTitle) => { + const task = SilentRenderer.tasks.find(task => task.title === taskTitle); + t.true(task.hasFailed(), `'${taskTitle}' did not fail!`); +}; + +export const assertTaskDisabled = (t, taskTitle) => { + const task = SilentRenderer.tasks.find(task => task.title === taskTitle); + t.true(!task.isEnabled(), `'${taskTitle}' was enabled!`); +}; + +export const assertTaskDoesntExist = (t, taskTitle) => { + t.true(SilentRenderer.tasks.every(task => task.title !== taskTitle), `'${taskTitle}' exists!`); +}; diff --git a/test/config.js b/test/config.js index 3c577f69..0800c14d 100644 --- a/test/config.js +++ b/test/config.js @@ -1,90 +1,100 @@ -import path from 'path'; +import path from 'node:path'; import test from 'ava'; import sinon from 'sinon'; -import proxyquire from 'proxyquire'; +import esmock from 'esmock'; -const fixtureBasePath = path.resolve('test', 'fixtures', 'config'); +const testedModulePath = '../source/config.js'; + +const getFixture = fixture => path.resolve('test', 'fixtures', 'config', fixture); +const getFixtures = fixtures => fixtures.map(fixture => getFixture(fixture)); const getConfigsWhenGlobalBinaryIsUsed = async homedirStub => { - const pathsPkgDir = [path.resolve(fixtureBasePath, 'pkg-dir'), - path.resolve(fixtureBasePath, 'local1'), - path.resolve(fixtureBasePath, 'local2'), - path.resolve(fixtureBasePath, 'local3')]; - - const promises = []; - pathsPkgDir.forEach(pathPkgDir => { - promises.push(proxyquire('../source/config', { + const pathsPkgDir = getFixtures(['pkg-dir', 'local1', 'local2', 'local3']); + + const promises = pathsPkgDir.map(async pathPkgDir => { + const getConfig = await esmock(testedModulePath, { 'is-installed-globally': true, - 'pkg-dir': async () => { - return pathPkgDir; - }, - os: { - homedir: homedirStub - } - })()); + 'pkg-dir': {packageDirectory: async () => pathPkgDir}, + 'node:os': {homedir: homedirStub}, + }); + return getConfig(); }); + return Promise.all(promises); }; const getConfigsWhenLocalBinaryIsUsed = async pathPkgDir => { - const homedirs = [path.resolve(fixtureBasePath, 'homedir1'), - path.resolve(fixtureBasePath, 'homedir2'), - path.resolve(fixtureBasePath, 'homedir3')]; + const homedirs = getFixtures(['homedir1', 'homedir2', 'homedir3']); - const promises = []; - homedirs.forEach(homedir => { - promises.push(proxyquire('../source/config', { + const promises = homedirs.map(async homedir => { + const getConfig = await esmock(testedModulePath, { 'is-installed-globally': false, - 'pkg-dir': async () => { - return pathPkgDir; - }, - os: { - homedir: () => { - return homedir; - } - } - })()); + 'pkg-dir': {packageDirectory: async () => pathPkgDir}, + 'node:os': {homedir: () => homedir}, + }); + return getConfig(); }); + return Promise.all(promises); }; -test('returns config from home directory when global binary is used and `.np-config-json` exists in home directory', async t => { - const homedirStub = sinon.stub(); - homedirStub.returns(path.resolve(fixtureBasePath, 'homedir1')); +const useGlobalBinary = test.macro(async (t, homedir, source) => { + const homedirStub = sinon.stub().returns(getFixture(homedir)); const configs = await getConfigsWhenGlobalBinaryIsUsed(homedirStub); - configs.forEach(config => t.deepEqual(config, {source: 'homedir/.np-config.json'})); -}); -test('returns config from home directory when global binary is used and `.np-config.js` exists in home directory', async t => { - const homedirStub = sinon.stub(); - homedirStub.returns(path.resolve(fixtureBasePath, 'homedir2')); - const configs = await getConfigsWhenGlobalBinaryIsUsed(homedirStub); - configs.forEach(config => t.deepEqual(config, {source: 'homedir/.np-config.js'})); + for (const config of configs) { + t.deepEqual(config, {source}); + } }); -test('returns config from home directory when global binary is used and `.np-config.cjs` exists in home directory', async t => { - const homedirStub = sinon.stub(); - homedirStub.returns(path.resolve(fixtureBasePath, 'homedir3')); - const configs = await getConfigsWhenGlobalBinaryIsUsed(homedirStub); - configs.forEach(config => t.deepEqual(config, {source: 'homedir/.np-config.cjs'})); -}); +const useLocalBinary = test.macro(async (t, pkgDir, source) => { + const configs = await getConfigsWhenLocalBinaryIsUsed(getFixture(pkgDir)); -test('returns config from package directory when local binary is used and `package.json` exists in package directory', async t => { - const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'pkg-dir')); - configs.forEach(config => t.deepEqual(config, {source: 'package.json'})); + for (const config of configs) { + t.deepEqual(config, {source}); + } }); -test('returns config from package directory when local binary is used and `.np-config.json` exists in package directory', async t => { - const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'local1')); - configs.forEach(config => t.deepEqual(config, {source: 'packagedir/.np-config.json'})); -}); +test('returns config from home directory when global binary is used and .np-config-json exists in home directory', + useGlobalBinary, 'homedir1', 'homedir/.np-config.json', +); -test('returns config from package directory when local binary is used and `.np-config.js` exists in package directory', async t => { - const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'local2')); - configs.forEach(config => t.deepEqual(config, {source: 'packagedir/.np-config.js'})); -}); +test('returns config from home directory when global binary is used and `.np-config.js` as CJS exists in home directory', + useGlobalBinary, 'homedir2', 'homedir/.np-config.js', +); -test('returns config from package directory when local binary is used and `.np-config.cjs` exists in package directory', async t => { - const configs = await getConfigsWhenLocalBinaryIsUsed(path.resolve(fixtureBasePath, 'local3')); - configs.forEach(config => t.deepEqual(config, {source: 'packagedir/.np-config.cjs'})); -}); +test('returns config from home directory when global binary is used and `.np-config.cjs` exists in home directory', + useGlobalBinary, 'homedir3', 'homedir/.np-config.cjs', +); + +test.failing('returns config from home directory when global binary is used and `.np-config.js` as ESM exists in home directory', + useGlobalBinary, 'homedir4', 'homedir/.np-config.js', +); + +test('returns config from home directory when global binary is used and `.np-config.mjs` exists in home directory', + useGlobalBinary, 'homedir5', 'homedir/.np-config.mjs', +); + +test('returns config from package directory when local binary is used and `package.json` exists in package directory', + useLocalBinary, 'pkg-dir', 'package.json', +); + +test('returns config from package directory when local binary is used and `.np-config.json` exists in package directory', + useLocalBinary, 'local1', 'packagedir/.np-config.json', +); + +test('returns config from package directory when local binary is used and `.np-config.js` as CJS exists in package directory', + useLocalBinary, 'local2', 'packagedir/.np-config.js', +); + +test('returns config from package directory when local binary is used and `.np-config.cjs` exists in package directory', + useLocalBinary, 'local3', 'packagedir/.np-config.cjs', +); + +test('returns config from package directory when local binary is used and `.np-config.js` as ESM exists in package directory', + useLocalBinary, 'local4', 'packagedir/.np-config.js', +); + +test('returns config from package directory when local binary is used and `.np-config.mjs` exists in package directory', + useLocalBinary, 'local5', 'packagedir/.np-config.mjs', +); diff --git a/test/fixtures/config/homedir4/.np-config.js b/test/fixtures/config/homedir4/.np-config.js new file mode 100644 index 00000000..a91f20d0 --- /dev/null +++ b/test/fixtures/config/homedir4/.np-config.js @@ -0,0 +1,3 @@ +export default { + source: 'homedir/.np-config.js' +}; diff --git a/test/fixtures/config/homedir5/.np-config.mjs b/test/fixtures/config/homedir5/.np-config.mjs new file mode 100644 index 00000000..7565b8fb --- /dev/null +++ b/test/fixtures/config/homedir5/.np-config.mjs @@ -0,0 +1,3 @@ +export default { + source: 'homedir/.np-config.mjs' +}; diff --git a/test/fixtures/config/local4/.np-config.js b/test/fixtures/config/local4/.np-config.js new file mode 100644 index 00000000..41bc0e49 --- /dev/null +++ b/test/fixtures/config/local4/.np-config.js @@ -0,0 +1,3 @@ +export default { + source: 'packagedir/.np-config.js' +}; diff --git a/test/fixtures/config/local4/package.json b/test/fixtures/config/local4/package.json new file mode 100644 index 00000000..6509d65a --- /dev/null +++ b/test/fixtures/config/local4/package.json @@ -0,0 +1,4 @@ +{ + "name": "use-type-module-for-config-fixtures", + "type": "module" +} diff --git a/test/fixtures/config/local5/.np-config.mjs b/test/fixtures/config/local5/.np-config.mjs new file mode 100644 index 00000000..90b0f8f5 --- /dev/null +++ b/test/fixtures/config/local5/.np-config.mjs @@ -0,0 +1,3 @@ +export default { + source: 'packagedir/.np-config.mjs' +}; diff --git a/test/fixtures/config/package.json b/test/fixtures/config/package.json new file mode 100644 index 00000000..7ad6eeb0 --- /dev/null +++ b/test/fixtures/config/package.json @@ -0,0 +1,3 @@ +{ + "name": "override-type-module-for-config-fixtures" +} diff --git a/test/fixtures/listr-renderer.js b/test/fixtures/listr-renderer.js index ee5982e6..9a9f2581 100644 --- a/test/fixtures/listr-renderer.js +++ b/test/fixtures/listr-renderer.js @@ -1,6 +1,6 @@ let tasks; -class SilentRenderer { +export class SilentRenderer { constructor(_tasks) { tasks = _tasks; } @@ -13,9 +13,11 @@ class SilentRenderer { return true; } - render() { } + static clearTasks() { + tasks = []; + } - end() { } -} + render() {} -module.exports.SilentRenderer = SilentRenderer; + end() {} +} diff --git a/test/git-tasks.js b/test/git-tasks.js index 56f5422a..d390d0c5 100644 --- a/test/git-tasks.js +++ b/test/git-tasks.js @@ -1,133 +1,144 @@ import test from 'ava'; -import execaStub from 'execa_test_double'; -import mockery from 'mockery'; -import {SilentRenderer} from './fixtures/listr-renderer'; - -let testedModule; - -const run = async listr => { - listr.setRenderer(SilentRenderer); - await listr.run(); -}; - -test.before(() => { - mockery.registerMock('execa', execaStub.execa); - mockery.enable({ - useCleanCache: true, - warnOnReplace: false, - warnOnUnregistered: false - }); - testedModule = require('../source/git-tasks'); -}); +import {SilentRenderer} from './fixtures/listr-renderer.js'; +import { + _stubExeca, + run, + assertTaskFailed, + assertTaskDoesntExist, +} from './_utils.js'; + +/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ +const stubExeca = _stubExeca('../source/git-tasks.js'); -test.beforeEach(() => { - execaStub.resetStub(); +test.afterEach(() => { + SilentRenderer.clearTasks(); }); test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', async t => { - execaStub.createStub([ - { - command: 'git symbolic-ref --short HEAD', - exitCode: 0, - stdout: 'feature' - } - ]); - await t.throwsAsync(run(testedModule({branch: 'master'})), - {message: 'Not on `master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check current branch' && task.hasFailed())); + const gitTasks = await stubExeca(t, [{ + command: 'git symbolic-ref --short HEAD', + exitCode: 0, + stdout: 'feature', + }]); + + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Not on `master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); + + assertTaskFailed(t, 'Check current branch'); }); test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', async t => { - execaStub.createStub([ - { - command: 'git symbolic-ref --short HEAD', - exitCode: 0, - stdout: 'feature' - } - ]); - await t.throwsAsync(run(testedModule({branch: 'release'})), - {message: 'Not on `release` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check current branch' && task.hasFailed())); + const gitTasks = await stubExeca(t, [{ + command: 'git symbolic-ref --short HEAD', + exitCode: 0, + stdout: 'feature', + }]); + + await t.throwsAsync( + run(gitTasks({branch: 'release'})), + {message: 'Not on `release` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); + + assertTaskFailed(t, 'Check current branch'); }); test.serial('should not fail when current branch not master and publishing from any branch permitted', async t => { - execaStub.createStub([ + const gitTasks = await stubExeca(t, [ { command: 'git symbolic-ref --short HEAD', exitCode: 0, - stdout: 'feature' + stdout: 'feature', }, { command: 'git status --porcelain', exitCode: 0, - stdout: '' + stdout: '', }, { command: 'git rev-list --count --left-only @{u}...HEAD', exitCode: 0, - stdout: '' - } + stdout: '', + }, ]); - await run(testedModule({anyBranch: true})); - t.false(SilentRenderer.tasks.some(task => task.title === 'Check current branch')); + + await t.notThrowsAsync( + run(gitTasks({anyBranch: true})), + ); + + assertTaskDoesntExist(t, 'Check current branch'); }); test.serial('should fail when local working tree modified', async t => { - execaStub.createStub([ + const gitTasks = await stubExeca(t, [ { command: 'git symbolic-ref --short HEAD', exitCode: 0, - stdout: 'master' + stdout: 'master', }, { command: 'git status --porcelain', exitCode: 0, - stdout: 'M source/git-tasks.js' - } + stdout: 'M source/git-tasks.js', + }, ]); - await t.throwsAsync(run(testedModule({branch: 'master'})), {message: 'Unclean working tree. Commit or stash changes first.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check local working tree' && task.hasFailed())); + + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); + + assertTaskFailed(t, 'Check local working tree'); }); test.serial('should fail when remote history differs', async t => { - execaStub.createStub([ + const gitTasks = await stubExeca(t, [ { command: 'git symbolic-ref --short HEAD', exitCode: 0, - stdout: 'master' + stdout: 'master', }, { command: 'git status --porcelain', exitCode: 0, - stdout: '' + stdout: '', }, { command: 'git rev-list --count --left-only @{u}...HEAD', exitCode: 0, - stdout: '1' - } + stdout: '1', + }, ]); - await t.throwsAsync(run(testedModule({branch: 'master'})), {message: 'Remote history differs. Please pull changes.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check remote history' && task.hasFailed())); + + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); }); test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => { - execaStub.createStub([ + const gitTasks = await stubExeca(t, [ { command: 'git symbolic-ref --short HEAD', exitCode: 0, - stdout: 'master' + stdout: 'master', }, { command: 'git status --porcelain', exitCode: 0, - stdout: '' + stdout: '', }, { command: 'git rev-list --count --left-only @{u}...HEAD', exitCode: 0, - stdout: '' - } + stdout: '', + }, ]); - await t.notThrowsAsync(run(testedModule({branch: 'master'}))); + + await t.notThrowsAsync( + run(gitTasks({branch: 'master'})), + ); }); diff --git a/test/hyperlinks.js b/test/hyperlinks.js index 963a9663..c5879174 100644 --- a/test/hyperlinks.js +++ b/test/hyperlinks.js @@ -1,7 +1,7 @@ import test from 'ava'; import sinon from 'sinon'; import terminalLink from 'terminal-link'; -import {linkifyIssues, linkifyCommit, linkifyCommitRange} from '../source/util'; +import {linkifyIssues, linkifyCommit, linkifyCommitRange} from '../source/util.js'; const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; const MOCK_COMMIT_HASH = '5063f8a'; diff --git a/test/index.js b/test/index.js index d011b7be..dbf61745 100644 --- a/test/index.js +++ b/test/index.js @@ -1,7 +1,7 @@ import test from 'ava'; import sinon from 'sinon'; -import proxyquire from 'proxyquire'; -import np from '../source'; +import esmock from 'esmock'; +import np from '../source/index.js'; const defaultOptions = { cleanup: true, @@ -10,53 +10,55 @@ const defaultOptions = { runPublish: true, availability: { isAvailable: false, - isUnknown: false - } + isUnknown: false, + }, + renderer: 'silent', }; -test('version is invalid', async t => { - const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.'; - await t.throwsAsync(np('foo', defaultOptions), message); - await t.throwsAsync(np('4.x.3', defaultOptions), message); +const npFails = test.macro(async (t, inputs, message) => { + await t.throwsAsync( + Promise.all(inputs.map(input => np(input, defaultOptions))), + {message}, + ); }); -test('version is pre-release', async t => { - const message = 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'; - await t.throwsAsync(np('premajor', defaultOptions), message); - await t.throwsAsync(np('preminor', defaultOptions), message); - await t.throwsAsync(np('prepatch', defaultOptions), message); - await t.throwsAsync(np('prerelease', defaultOptions), message); - await t.throwsAsync(np('10.0.0-0', defaultOptions), message); - await t.throwsAsync(np('10.0.0-beta', defaultOptions), message); -}); +test('version is invalid', npFails, + ['foo', '4.x.3'], + 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.', +); -test('errors on too low version', async t => { - await t.throwsAsync(np('1.0.0', defaultOptions), /New version `1\.0\.0` should be higher than current version `\d+\.\d+\.\d+`/); - await t.throwsAsync(np('1.0.0-beta', defaultOptions), /New version `1\.0\.0-beta` should be higher than current version `\d+\.\d+\.\d+`/); -}); +test('version is pre-release', npFails, + ['premajor', 'preminor', 'prepatch', 'prerelease', '10.0.0-0', '10.0.0-beta'], + 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag', +); + +test('errors on too low version', npFails, + ['1.0.0', '1.0.0-beta'], + /New version `1\.0\.0(?:-beta)?` should be higher than current version `\d+\.\d+\.\d+`/, +); test('skip enabling 2FA if the package exists', async t => { const enable2faStub = sinon.stub(); - const np = proxyquire('../source', { - del: sinon.stub(), - execa: sinon.stub().returns({pipe: sinon.stub()}), - './prerequisite-tasks': sinon.stub(), - './git-tasks': sinon.stub(), - './git-util': { + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns({pipe: sinon.stub()})}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { hasUpstream: sinon.stub().returns(true), - pushGraceful: sinon.stub() + pushGraceful: sinon.stub(), }, - './npm/enable-2fa': enable2faStub, - './npm/publish': sinon.stub().returns({pipe: sinon.stub()}) - }); + '../source/npm/enable-2fa.js': enable2faStub, + '../source/npm/publish.js': sinon.stub().returns({pipe: sinon.stub()}), + }, {}); - await t.notThrowsAsync(np('1.0.0', { + await t.notThrowsAsync(npMock('1.0.0', { ...defaultOptions, availability: { isAvailable: false, - isUnknown: false - } + isUnknown: false, + }, })); t.true(enable2faStub.notCalled); @@ -65,26 +67,26 @@ test('skip enabling 2FA if the package exists', async t => { test('skip enabling 2FA if the `2fa` option is false', async t => { const enable2faStub = sinon.stub(); - const np = proxyquire('../source', { - del: sinon.stub(), - execa: sinon.stub().returns({pipe: sinon.stub()}), - './prerequisite-tasks': sinon.stub(), - './git-tasks': sinon.stub(), - './git-util': { + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns({pipe: sinon.stub()})}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { hasUpstream: sinon.stub().returns(true), - pushGraceful: sinon.stub() + pushGraceful: sinon.stub(), }, - './npm/enable-2fa': enable2faStub, - './npm/publish': sinon.stub().returns({pipe: sinon.stub()}) + '../source/npm/enable-2fa.js': enable2faStub, + '../source/npm/publish.js': sinon.stub().returns({pipe: sinon.stub()}), }); - await t.notThrowsAsync(np('1.0.0', { + await t.notThrowsAsync(npMock('1.0.0', { ...defaultOptions, availability: { isAvailable: true, - isUnknown: false + isUnknown: false, }, - '2fa': false + '2fa': false, })); t.true(enable2faStub.notCalled); diff --git a/test/integration.js b/test/integration.js index a26daab7..83d43d6e 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1,11 +1,104 @@ -const test = require('ava'); -const execa = require('execa'); +/* eslint-disable ava/no-ignored-test-files */ +import process from 'node:process'; +import path from 'node:path'; +import fs from 'fs-extra'; +import test from 'ava'; +import {$} from 'execa'; +import {deleteAsync} from 'del'; +import * as gitUtil from '../source/git-util.js'; +import * as util from '../source/util.js'; + +test.before(async t => { + await fs.emptyDir('integration'); + process.chdir('integration'); + + await $`git init`; + await t.throwsAsync(gitUtil.latestTag(), undefined, 'prerequisites not met: repository should not contain any tags'); + + await fs.createFile('temp'); + await $`git add .`; + await $`git commit -m 'init'`; + await deleteAsync('temp'); +}); test.after.always(async () => { - await execa('git', ['submodule', 'update', '--remote']); + process.chdir('..'); + await deleteAsync('integration'); +}); + +test.afterEach.always(async t => { + if (typeof t.context.teardown === 'function') { + await t.context.teardown(); + } }); -test('Integration tests', async t => { - await execa('npx', ['ava'], {cwd: 'integration-test'}); - t.pass(); +test.serial('files to package with tags added', async t => { + await $`git tag v0.0.0`; + await fs.createFile('new'); + await fs.createFile('index.js'); + await $`git add new index.js`; + await $`git commit -m "added"`; + + t.context.teardown = async () => { + await $`git rm new`; + await $`git rm index.js`; + await $`git tag -d v0.0.0`; + await $`git commit -m "deleted"`; + }; + + t.deepEqual( + await util.getNewFiles({files: ['*.js']}), + {unpublished: ['new'], firstTime: ['index.js']}, + ); +}); + +test.serial.failing('file `new` to package without tags added', async t => { + await fs.createFile('new'); + await fs.createFile('index.js'); + + t.context.teardown = async () => { + await deleteAsync(['new', 'index.js']); + }; + + t.deepEqual( + await util.getNewFiles({files: ['index.js']}), + {unpublished: ['new'], firstTime: ['index.js']}, + ); +}); + +test.serial('files with long pathnames added', async t => { + const longPath = path.join('veryLonggggggDirectoryName', 'veryLonggggggDirectoryName'); + const filePath1 = path.join(longPath, 'file1'); + const filePath2 = path.join(longPath, 'file2'); + + await $`git tag v0.0.0`; + await fs.mkdir(longPath, {recursive: true}); + await fs.createFile(filePath1); + await fs.createFile(filePath2); + await $`git add ${filePath1} ${filePath2}`; + await $`git commit -m "added"`; + + t.context.teardown = async () => { + await $`git rm -r ${longPath}`; + await $`git tag -d v0.0.0`; + await $`git commit -m "deleted"`; + }; + + t.deepEqual( + await util.getNewFiles({files: ['*.js']}), + {unpublished: [filePath1, filePath2], firstTime: []}, + ); +}); + +test.serial('no new files added', async t => { + await $`git tag v0.0.0`; + + t.context.teardown = async () => { + await $`git tag -d v0.0.0`; + }; + + t.deepEqual( + await util.getNewFiles({files: ['*.js']}), + {unpublished: [], firstTime: []}, + ); }); diff --git a/test/npmignore.js b/test/npmignore.js index 8bb40c97..4f02492a 100644 --- a/test/npmignore.js +++ b/test/npmignore.js @@ -1,6 +1,6 @@ -import path from 'path'; +import path from 'node:path'; import test from 'ava'; -import proxyquire from 'proxyquire'; +import esmock from 'esmock'; const newFiles = [ 'source/ignore.txt', @@ -8,167 +8,111 @@ const newFiles = [ '.hg', 'test/file.txt', 'readme.md', - 'README.txt' + 'README.txt', ]; -test('ignored files using file-attribute in package.json with one file', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'package') - } - }); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['pay_attention.txt']}, newFiles), ['source/ignore.txt']); -}); - -test('ignored file using file-attribute in package.json with directory', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'package') - } - }); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source']}, newFiles), []); -}); - -test('ignored test files using files attribute and directory structure in package.json', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'package') - } - }); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source'], directories: {test: 'test-tap'}}, newFiles), ['test/file.txt']); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source'], directories: {test: ['test-tap']}}, newFiles), ['test/file.txt']); -}); - -test('ignored files using .npmignore', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'npmignore') - } - }); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({name: 'npmignore'}, newFiles), ['source/ignore.txt']); -}); - -test('ignored test files using files attribute and .npmignore', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'npmignore') - } - }); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({directories: {test: 'test-tap'}}, newFiles), ['source/ignore.txt', 'test/file.txt']); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({directories: {test: ['test-tap']}}, newFiles), ['source/ignore.txt', 'test/file.txt']); -}); - -test('ignored files - dot files using files attribute', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'package') - } - }); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({files: ['source']}, ['test/.dotfile']), []); -}); - -test('ignored files - dot files using .npmignore', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'npmignore') - } - }); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({}, ['test/.dot']), []); -}); - -test('ignored files - ignore strategy is not used', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures') - } - }); - t.deepEqual(await testedModule.getNewAndUnpublishedFiles({name: 'no ignore strategy'}, newFiles), []); -}); - -test('first time published files using file-attribute in package.json with one file', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'package') - } - }); - t.deepEqual(await testedModule.getFirstTimePublishedFiles({files: ['pay_attention.txt']}, newFiles), ['source/pay_attention.txt']); -}); - -test('first time published files using file-attribute in package.json with directory', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'package') - } - }); - t.deepEqual(await testedModule.getFirstTimePublishedFiles({files: ['source']}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt']); -}); - -test('first time published files using .npmignore', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'npmignore') - } - }); - t.deepEqual(await testedModule.getFirstTimePublishedFiles({name: 'npmignore'}, newFiles), ['source/pay_attention.txt']); -}); - -test('first time published dot files using files attribute', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'package') - } - }); - t.deepEqual(await testedModule.getFirstTimePublishedFiles({files: ['source']}, ['source/.dotfile']), ['source/.dotfile']); -}); - -test('first time published dot files using .npmignore', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'npmignore') - } - }); - t.deepEqual(await testedModule.getFirstTimePublishedFiles({}, ['source/.dotfile']), ['source/.dotfile']); -}); - -test('first time published files - ignore strategy is not used', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures') - } - }); - t.deepEqual(await testedModule.getFirstTimePublishedFiles({name: 'no ignore strategy'}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt', 'test/file.txt']); -}); - -test('first time published files - empty files property', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'package') - } - }); - t.deepEqual(await testedModule.getFirstTimePublishedFiles({files: []}, newFiles), []); -}); - -test('first time published files - .npmignore excludes everything', async t => { - const testedModule = proxyquire('../source/npm/util', { - 'pkg-dir': - { - sync: () => path.resolve('test', 'fixtures', 'npmignore') - } - }); - t.deepEqual(await testedModule.getFirstTimePublishedFiles({name: 'excluded everything'}, ['source/ignore.txt']), []); -}); +const mockPkgDir = test.macro(async (t, paths, impl) => { + const testedModule = await esmock('../source/npm/util.js', { + 'pkg-dir': {packageDirectorySync: () => path.resolve(...paths)}, + }); + + await impl(t, testedModule); +}); + +test.serial('ignored files using file-attribute in package.json with one file', mockPkgDir, ['test', 'fixtures', 'package'], + async (t, {getNewAndUnpublishedFiles}) => { + t.deepEqual(await getNewAndUnpublishedFiles({files: ['pay_attention.txt']}, newFiles), ['source/ignore.txt']); + }, +); + +test.serial('ignored file using file-attribute in package.json with directory', mockPkgDir, ['test', 'fixtures', 'package'], + async (t, {getNewAndUnpublishedFiles}) => { + t.deepEqual(await getNewAndUnpublishedFiles({files: ['source']}, newFiles), []); + }, +); + +test.serial('ignored test files using files attribute and directory structure in package.json', mockPkgDir, ['test', 'fixtures', 'package'], + async (t, {getNewAndUnpublishedFiles}) => { + t.deepEqual(await getNewAndUnpublishedFiles({files: ['source'], directories: {test: 'test-tap'}}, newFiles), ['test/file.txt']); + t.deepEqual(await getNewAndUnpublishedFiles({files: ['source'], directories: {test: ['test-tap']}}, newFiles), ['test/file.txt']); + }, +); + +test.serial('ignored files using .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], + async (t, {getNewAndUnpublishedFiles}) => { + t.deepEqual(await getNewAndUnpublishedFiles({name: 'npmignore'}, newFiles), ['source/ignore.txt']); + }, +); + +test.serial('ignored test files using files attribute and .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], + async (t, {getNewAndUnpublishedFiles}) => { + t.deepEqual(await getNewAndUnpublishedFiles({directories: {test: 'test-tap'}}, newFiles), ['source/ignore.txt', 'test/file.txt']); + t.deepEqual(await getNewAndUnpublishedFiles({directories: {test: ['test-tap']}}, newFiles), ['source/ignore.txt', 'test/file.txt']); + }, +); + +test.serial('ignored files - dot files using files attribute', mockPkgDir, ['test', 'fixtures', 'package'], + async (t, {getNewAndUnpublishedFiles}) => { + t.deepEqual(await getNewAndUnpublishedFiles({files: ['source']}, ['test/.dotfile']), []); + }, +); + +test.serial('ignored files - dot files using .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], + async (t, {getNewAndUnpublishedFiles}) => { + t.deepEqual(await getNewAndUnpublishedFiles({}, ['test/.dot']), []); + }, +); + +test.serial('ignored files - ignore strategy is not used', mockPkgDir, ['test', 'fixtures'], + async (t, {getNewAndUnpublishedFiles}) => { + t.deepEqual(await getNewAndUnpublishedFiles({name: 'no ignore strategy'}, newFiles), []); + }, +); + +test.serial('first time published files using file-attribute in package.json with one file', mockPkgDir, ['test', 'fixtures', 'package'], + async (t, {getFirstTimePublishedFiles}) => { + t.deepEqual(await getFirstTimePublishedFiles({files: ['pay_attention.txt']}, newFiles), ['source/pay_attention.txt']); + }, +); + +test.serial('first time published files using file-attribute in package.json with directory', mockPkgDir, ['test', 'fixtures', 'package'], + async (t, {getFirstTimePublishedFiles}) => { + t.deepEqual(await getFirstTimePublishedFiles({files: ['source']}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt']); + }, +); + +test.serial('first time published files using .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], + async (t, {getFirstTimePublishedFiles}) => { + t.deepEqual(await getFirstTimePublishedFiles({name: 'npmignore'}, newFiles), ['source/pay_attention.txt']); + }, +); + +test.serial('first time published dot files using files attribute', mockPkgDir, ['test', 'fixtures', 'package'], + async (t, {getFirstTimePublishedFiles}) => { + t.deepEqual(await getFirstTimePublishedFiles({files: ['source']}, ['source/.dotfile']), ['source/.dotfile']); + }, +); + +test.serial('first time published dot files using .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], + async (t, {getFirstTimePublishedFiles}) => { + t.deepEqual(await getFirstTimePublishedFiles({}, ['source/.dotfile']), ['source/.dotfile']); + }, +); + +test.serial('first time published files - ignore strategy is not used', mockPkgDir, ['test', 'fixtures'], + async (t, {getFirstTimePublishedFiles}) => { + t.deepEqual(await getFirstTimePublishedFiles({name: 'no ignore strategy'}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt', 'test/file.txt']); + }, +); + +test.serial('first time published files - empty files property', mockPkgDir, ['test', 'fixtures', 'package'], + async (t, {getFirstTimePublishedFiles}) => { + t.deepEqual(await getFirstTimePublishedFiles({files: []}, newFiles), []); + }, +); + +test.serial('first time published files - .npmignore excludes everything', mockPkgDir, ['test', 'fixtures', 'npmignore'], + async (t, {getFirstTimePublishedFiles}) => { + t.deepEqual(await getFirstTimePublishedFiles({name: 'excluded everything'}, ['source/ignore.txt']), []); + }, +); diff --git a/test/prefix.js b/test/prefix.js index 6ede56a6..7eb5ef8c 100644 --- a/test/prefix.js +++ b/test/prefix.js @@ -1,6 +1,7 @@ import test from 'ava'; -import proxyquire from 'proxyquire'; -import {getTagVersionPrefix} from '../source/util'; +import esmock from 'esmock'; +import {stripIndent} from 'common-tags'; +import {getTagVersionPrefix} from '../source/util.js'; test('get tag prefix', async t => { t.is(await getTagVersionPrefix({yarn: false}), 'v'); @@ -8,11 +9,17 @@ test('get tag prefix', async t => { }); test('no options passed', async t => { - await t.throwsAsync(getTagVersionPrefix(), {message: 'Expected `options` to be of type `object` but received type `undefined`'}); - await t.throwsAsync(getTagVersionPrefix({}), {message: 'Expected object `options` to have keys `["yarn"]`'}); + await t.throwsAsync(getTagVersionPrefix(), {message: stripIndent` + Expected argument to be of type \`object\` but received type \`undefined\` + Expected object to have keys \`["yarn"]\` + `}); + await t.throwsAsync(getTagVersionPrefix({}), {message: 'Expected object to have keys `["yarn"]`'}); }); test.serial('defaults to "v" when command fails', async t => { - proxyquire('../source/util', {execa: Promise.reject}); - t.is(await getTagVersionPrefix({yarn: true}), 'v'); + const testedModule = await esmock('../source/util.js', { + execa: {default: Promise.reject}, + }); + + t.is(await testedModule.getTagVersionPrefix({yarn: true}), 'v'); }); diff --git a/test/preid.js b/test/preid.js index 2bdf5295..b83bcd84 100644 --- a/test/preid.js +++ b/test/preid.js @@ -1,5 +1,6 @@ import test from 'ava'; -import {getPreReleasePrefix} from '../source/util'; +import {stripIndent} from 'common-tags'; +import {getPreReleasePrefix} from '../source/util.js'; test('get preId postfix', async t => { t.is(await getPreReleasePrefix({yarn: false}), ''); @@ -7,6 +8,9 @@ test('get preId postfix', async t => { }); test('no options passed', async t => { - await t.throwsAsync(getPreReleasePrefix(), {message: 'Expected `options` to be of type `object` but received type `undefined`'}); - await t.throwsAsync(getPreReleasePrefix({}), {message: 'Expected object `options` to have keys `["yarn"]`'}); + await t.throwsAsync(getPreReleasePrefix(), {message: stripIndent` + Expected argument to be of type \`object\` but received type \`undefined\` + Expected object to have keys \`["yarn"]\` + `}); + await t.throwsAsync(getPreReleasePrefix({}), {message: 'Expected object to have keys `["yarn"]`'}); }); diff --git a/test/prerequisite-tasks.js b/test/prerequisite-tasks.js index 7c42f8ee..e693e6f8 100644 --- a/test/prerequisite-tasks.js +++ b/test/prerequisite-tasks.js @@ -1,244 +1,295 @@ +import process from 'node:process'; import test from 'ava'; -import execaStub from 'execa_test_double'; -import mockery from 'mockery'; -import version from '../source/version'; -import {SilentRenderer} from './fixtures/listr-renderer'; +import {readPackageUp} from 'read-pkg-up'; +import Version from '../source/version.js'; +import actualPrerequisiteTasks from '../source/prerequisite-tasks.js'; +import {SilentRenderer} from './fixtures/listr-renderer.js'; +import { + _stubExeca, + run, + assertTaskFailed, + assertTaskDisabled, +} from './_utils.js'; -let testedModule; +/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ +const stubExeca = _stubExeca('../source/prerequisite-tasks.js'); +const {packageJson: pkg} = await readPackageUp(); -const run = async listr => { - listr.setRenderer(SilentRenderer); - await listr.run(); -}; - -test.before(() => { - mockery.registerMock('execa', execaStub.execa); - mockery.enable({ - useCleanCache: true, - warnOnReplace: false, - warnOnUnregistered: false - }); - testedModule = require('../source/prerequisite-tasks'); -}); - -test.beforeEach(() => { - execaStub.resetStub(); +test.afterEach(() => { + SilentRenderer.clearTasks(); }); test.serial('public-package published on npm registry: should fail when npm registry not pingable', async t => { - execaStub.createStub([{ + const prerequisiteTasks = await stubExeca(t, [{ command: 'npm ping', exitCode: 1, exitCodeName: 'EPERM', stdout: '', - stderr: 'failed' + stderr: 'failed', }]); - await t.throwsAsync(run(testedModule('1.0.0', {name: 'test'}, {})), - {message: 'Connection to npm registry failed'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Ping npm registry' && task.hasFailed())); + + await t.throwsAsync( + run(prerequisiteTasks('1.0.0', {name: 'test'}, {})), + {message: 'Connection to npm registry failed'}, + ); + + assertTaskFailed(t, 'Ping npm registry'); }); test.serial('private package: should disable task pinging npm registry', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '' - } - ]); - await run(testedModule('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})); - t.true(SilentRenderer.tasks.some(task => task.title === 'Ping npm registry' && !task.isEnabled())); + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + exitCode: 0, + stdout: '', + }]); + + await t.notThrowsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), + ); + + assertTaskDisabled(t, 'Ping npm registry'); }); test.serial('external registry: should disable task pinging npm registry', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '' - } - ]); - await run(testedModule('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, - {yarn: false})); - t.true(SilentRenderer.tasks.some(task => task.title === 'Ping npm registry' && !task.isEnabled())); + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + exitCode: 0, + stdout: '', + }]); + + await t.notThrowsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {yarn: false})), + ); + + assertTaskDisabled(t, 'Ping npm registry'); }); test.serial('should fail when npm version does not match range in `package.json`', async t => { - execaStub.createStub([ + const prerequisiteTasks = await stubExeca(t, [ { command: 'npm --version', exitCode: 0, - stdout: '6.0.0' + stdout: '6.0.0', }, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', exitCode: 0, - stdout: '' - } + stdout: '', + }, ]); - const depRange = require('../package.json').engines.npm; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Please upgrade to npm${depRange}`}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check npm version' && task.hasFailed())); + + const depRange = pkg.engines.npm; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + {message: `Please upgrade to npm${depRange}`}, + ); + + assertTaskFailed(t, 'Check npm version'); }); test.serial('should fail when yarn version does not match range in `package.json`', async t => { - execaStub.createStub([ + const prerequisiteTasks = await stubExeca(t, [ { command: 'yarn --version', exitCode: 0, - stdout: '1.0.0' + stdout: '1.0.0', }, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', exitCode: 0, - stdout: '' - } + stdout: '', + }, ]); - const depRange = require('../package.json').engines.yarn; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: true})), - {message: `Please upgrade to yarn${depRange}`}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check yarn version' && task.hasFailed())); + + const depRange = pkg.engines.yarn; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: true})), + {message: `Please upgrade to yarn${depRange}`}, + ); + + assertTaskFailed(t, 'Check yarn version'); }); test.serial('should fail when user is not authenticated at npm registry', async t => { - execaStub.createStub([ + const prerequisiteTasks = await stubExeca(t, [ { command: 'npm whoami', exitCode: 0, - stdout: 'sindresorhus' + stdout: 'sindresorhus', }, { command: 'npm access ls-collaborators test', exitCode: 0, - stdout: '{"sindresorhus": "read"}' - } + stdout: '{"sindresorhus": "read"}', + }, ]); + process.env.NODE_ENV = 'P'; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'You do not have write permissions required to publish this package.'}); + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + {message: 'You do not have write permissions required to publish this package.'}, + ); + process.env.NODE_ENV = 'test'; - t.true(SilentRenderer.tasks.some(task => task.title === 'Verify user is authenticated' && task.hasFailed())); + + assertTaskFailed(t, 'Verify user is authenticated'); }); test.serial('should fail when user is not authenticated at external registry', async t => { - execaStub.createStub([ + const prerequisiteTasks = await stubExeca(t, [ { command: 'npm whoami --registry http://my.io', exitCode: 0, - stdout: 'sindresorhus' + stdout: 'sindresorhus', }, { command: 'npm access ls-collaborators test --registry http://my.io', exitCode: 0, - stdout: '{"sindresorhus": "read"}' - } + stdout: '{"sindresorhus": "read"}', + }, + { + command: 'npm access list collaborators test --json --registry http://my.io', + exitCode: 0, + stdout: '{"sindresorhus": "read"}', + }, ]); + process.env.NODE_ENV = 'P'; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {yarn: false})), - {message: 'You do not have write permissions required to publish this package.'}); + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {yarn: false})), + {message: 'You do not have write permissions required to publish this package.'}, + ); + process.env.NODE_ENV = 'test'; - t.true(SilentRenderer.tasks.some(task => task.title === 'Verify user is authenticated' && task.hasFailed())); + + assertTaskFailed(t, 'Verify user is authenticated'); }); test.serial('private package: should disable task `verify user is authenticated`', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '' - } - ]); + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + exitCode: 0, + stdout: '', + }]); + process.env.NODE_ENV = 'P'; - await run(testedModule('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})); + + await t.notThrowsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), + ); + process.env.NODE_ENV = 'test'; - t.true(SilentRenderer.tasks.some(task => task.title === 'Verify user is authenticated' && !task.isEnabled())); + + assertTaskDisabled(t, 'Verify user is authenticated'); }); test.serial('should fail when git version does not match range in `package.json`', async t => { - execaStub.createStub([ - { - command: 'git version', - exitCode: 0, - stdout: 'git version 1.0.0' - } - ]); - const depRange = require('../package.json').engines.git; - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Please upgrade to git${depRange}`}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check git version' && task.hasFailed())); + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git version', + exitCode: 0, + stdout: 'git version 1.0.0', + }]); + + const depRange = pkg.engines.git; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + {message: `Please upgrade to git${depRange}`}, + ); + + assertTaskFailed(t, 'Check git version'); }); -test.serial('should fail when git remote does not exists', async t => { - execaStub.createStub([ - { - command: 'git ls-remote origin HEAD', - exitCode: 1, - exitCodeName: 'EPERM', - stderr: 'not found' - } - ]); - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'not found'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check git remote' && task.hasFailed())); +test.serial('should fail when git remote does not exist', async t => { + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git ls-remote origin HEAD', + exitCode: 1, + exitCodeName: 'EPERM', + stderr: 'not found', + }]); + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + {message: 'not found'}, + ); + + assertTaskFailed(t, 'Check git remote'); }); test.serial('should fail when version is invalid', async t => { - await t.throwsAsync(run(testedModule('DDD', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Version should be either ${version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Validate version' && task.hasFailed())); + await t.throwsAsync( + run(actualPrerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {yarn: false})), + {message: `Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}, + ); + + assertTaskFailed(t, 'Validate version'); }); test.serial('should fail when version is lower as latest version', async t => { - await t.throwsAsync(run(testedModule('0.1.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'New version `0.1.0` should be higher than current version `1.0.0`'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Validate version' && task.hasFailed())); + await t.throwsAsync( + run(actualPrerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + {message: 'New version `0.1.0` should be higher than current version `1.0.0`'}, + ); + + assertTaskFailed(t, 'Validate version'); }); test.serial('should fail when prerelease version of public package without dist tag given', async t => { - await t.throwsAsync(run(testedModule('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check for pre-release version' && task.hasFailed())); + await t.throwsAsync( + run(actualPrerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false})), + {message: 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'}, + ); + + assertTaskFailed(t, 'Check for pre-release version'); }); test.serial('should not fail when prerelease version of public package with dist tag given', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '' - } - ]); - await t.notThrowsAsync(run(testedModule('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false, tag: 'pre'}))); + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', + }]); + + await t.notThrowsAsync( + run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false, tag: 'pre'})), + ); }); test.serial('should not fail when prerelease version of private package without dist tag given', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '' - } - ]); - await t.notThrowsAsync(run(testedModule('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {yarn: false}))); + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', + }]); + + await t.notThrowsAsync( + run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), + ); }); test.serial('should fail when git tag already exists', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: 'vvb' - } - ]); - await t.throwsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'Git tag `v2.0.0` already exists.'}); - t.true(SilentRenderer.tasks.some(task => task.title === 'Check git tag existence' && task.hasFailed())); + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: 'vvb', + }]); + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + {message: 'Git tag `v2.0.0` already exists.'}, + ); + + assertTaskFailed(t, 'Check git tag existence'); }); test.serial('checks should pass', async t => { - execaStub.createStub([ - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '' - } - ]); - await t.notThrowsAsync(run(testedModule('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false}))); + const prerequisiteTasks = await stubExeca(t, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', + }]); + + await t.notThrowsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + ); }); diff --git a/test/version.js b/test/version.js index 07b8c056..203fb4e2 100644 --- a/test/version.js +++ b/test/version.js @@ -1,139 +1,139 @@ import test from 'ava'; -import version from '../source/version'; +import Version from '../source/version.js'; test('version.SEMVER_INCREMENTS', t => { - t.deepEqual(version.SEMVER_INCREMENTS, ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']); + t.deepEqual(Version.SEMVER_INCREMENTS, ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']); }); test('version.PRERELEASE_VERSIONS', t => { - t.deepEqual(version.PRERELEASE_VERSIONS, ['prepatch', 'preminor', 'premajor', 'prerelease']); + t.deepEqual(Version.PRERELEASE_VERSIONS, ['prepatch', 'preminor', 'premajor', 'prerelease']); }); test('version.isValidInput', t => { - t.false(version.isValidInput(null)); - t.false(version.isValidInput('foo')); - t.false(version.isValidInput('1.0.0.0')); - - t.true(version.isValidInput('patch')); - t.true(version.isValidInput('minor')); - t.true(version.isValidInput('major')); - t.true(version.isValidInput('prepatch')); - t.true(version.isValidInput('preminor')); - t.true(version.isValidInput('premajor')); - t.true(version.isValidInput('prerelease')); - t.true(version.isValidInput('1.0.0')); - t.true(version.isValidInput('1.1.0')); - t.true(version.isValidInput('1.0.1')); - t.true(version.isValidInput('1.0.0-beta')); - t.true(version.isValidInput('2.0.0-rc.2')); + t.false(Version.isValidInput(null)); + t.false(Version.isValidInput('foo')); + t.false(Version.isValidInput('1.0.0.0')); + + t.true(Version.isValidInput('patch')); + t.true(Version.isValidInput('minor')); + t.true(Version.isValidInput('major')); + t.true(Version.isValidInput('prepatch')); + t.true(Version.isValidInput('preminor')); + t.true(Version.isValidInput('premajor')); + t.true(Version.isValidInput('prerelease')); + t.true(Version.isValidInput('1.0.0')); + t.true(Version.isValidInput('1.1.0')); + t.true(Version.isValidInput('1.0.1')); + t.true(Version.isValidInput('1.0.0-beta')); + t.true(Version.isValidInput('2.0.0-rc.2')); }); test('version.isPrerelease', t => { - t.false(version('1.0.0').isPrerelease()); - t.false(version('1.1.0').isPrerelease()); - t.false(version('1.0.1').isPrerelease()); + t.false(new Version('1.0.0').isPrerelease()); + t.false(new Version('1.1.0').isPrerelease()); + t.false(new Version('1.0.1').isPrerelease()); - t.true(version('1.0.0-beta').isPrerelease()); - t.true(version('2.0.0-rc.2').isPrerelease()); + t.true(new Version('1.0.0-beta').isPrerelease()); + t.true(new Version('2.0.0-rc.2').isPrerelease()); }); test('version.isPrereleaseOrIncrement', t => { - t.false(version.isPrereleaseOrIncrement('patch')); - t.false(version.isPrereleaseOrIncrement('minor')); - t.false(version.isPrereleaseOrIncrement('major')); - - t.true(version.isPrereleaseOrIncrement('prepatch')); - t.true(version.isPrereleaseOrIncrement('preminor')); - t.true(version.isPrereleaseOrIncrement('premajor')); - t.true(version.isPrereleaseOrIncrement('prerelease')); + t.false(Version.isPrereleaseOrIncrement('patch')); + t.false(Version.isPrereleaseOrIncrement('minor')); + t.false(Version.isPrereleaseOrIncrement('major')); + + t.true(Version.isPrereleaseOrIncrement('prepatch')); + t.true(Version.isPrereleaseOrIncrement('preminor')); + t.true(Version.isPrereleaseOrIncrement('premajor')); + t.true(Version.isPrereleaseOrIncrement('prerelease')); }); test('version.getNewVersionFrom', t => { const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.'; - t.throws(() => version('1.0.0').getNewVersionFrom('patchxxx'), message); - t.throws(() => version('1.0.0').getNewVersionFrom('1.0.0.0'), message); + t.throws(() => new Version('1.0.0').getNewVersionFrom('patchxxx'), {message}); + t.throws(() => new Version('1.0.0').getNewVersionFrom('1.0.0.0'), {message}); - t.is(version('1.0.0').getNewVersionFrom('patch'), '1.0.1'); - t.is(version('1.0.0').getNewVersionFrom('minor'), '1.1.0'); - t.is(version('1.0.0').getNewVersionFrom('major'), '2.0.0'); + t.is(new Version('1.0.0').getNewVersionFrom('patch'), '1.0.1'); + t.is(new Version('1.0.0').getNewVersionFrom('minor'), '1.1.0'); + t.is(new Version('1.0.0').getNewVersionFrom('major'), '2.0.0'); - t.is(version('1.0.0-beta').getNewVersionFrom('major'), '1.0.0'); - t.is(version('1.0.0').getNewVersionFrom('prepatch'), '1.0.1-0'); - t.is(version('1.0.1-0').getNewVersionFrom('prepatch'), '1.0.2-0'); + t.is(new Version('1.0.0-beta').getNewVersionFrom('major'), '1.0.0'); + t.is(new Version('1.0.0').getNewVersionFrom('prepatch'), '1.0.1-0'); + t.is(new Version('1.0.1-0').getNewVersionFrom('prepatch'), '1.0.2-0'); - t.is(version('1.0.0-0').getNewVersionFrom('prerelease'), '1.0.0-1'); - t.is(version('1.0.1-0').getNewVersionFrom('prerelease'), '1.0.1-1'); + t.is(new Version('1.0.0-0').getNewVersionFrom('prerelease'), '1.0.0-1'); + t.is(new Version('1.0.1-0').getNewVersionFrom('prerelease'), '1.0.1-1'); }); test('version.validate', t => { const message = 'Version should be a valid semver version.'; - t.throws(() => version.validate('patch'), message); - t.throws(() => version.validate('patchxxx'), message); - t.throws(() => version.validate('1.0.0.0'), message); + t.throws(() => Version.validate('patch'), {message}); + t.throws(() => Version.validate('patchxxx'), {message}); + t.throws(() => Version.validate('1.0.0.0'), {message}); - t.notThrows(() => version.validate('1.0.0')); - t.notThrows(() => version.validate('1.0.0-beta')); - t.notThrows(() => version.validate('1.0.0-0')); + t.notThrows(() => Version.validate('1.0.0')); + t.notThrows(() => Version.validate('1.0.0-beta')); + t.notThrows(() => Version.validate('1.0.0-0')); }); test('version.isGreaterThanOrEqualTo', t => { - t.false(version('1.0.0').isGreaterThanOrEqualTo('0.0.1')); - t.false(version('1.0.0').isGreaterThanOrEqualTo('0.1.0')); + t.false(new Version('1.0.0').isGreaterThanOrEqualTo('0.0.1')); + t.false(new Version('1.0.0').isGreaterThanOrEqualTo('0.1.0')); - t.false(version('1.0.0').isGreaterThanOrEqualTo('1.0.0-0')); - t.false(version('1.0.0').isGreaterThanOrEqualTo('1.0.0-beta')); + t.false(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0-0')); + t.false(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0-beta')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('1.0.0')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('1.0.1')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('1.1.0')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('2.0.0')); + t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0')); + t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.1')); + t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.1.0')); + t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('2.0.0-0')); - t.true(version('1.0.0').isGreaterThanOrEqualTo('2.0.0-beta')); + t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0-0')); + t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0-beta')); }); test('version.isLowerThanOrEqualTo', t => { - t.true(version('1.0.0').isLowerThanOrEqualTo('0.0.1')); - t.true(version('1.0.0').isLowerThanOrEqualTo('0.1.0')); + t.true(new Version('1.0.0').isLowerThanOrEqualTo('0.0.1')); + t.true(new Version('1.0.0').isLowerThanOrEqualTo('0.1.0')); - t.true(version('1.0.0').isLowerThanOrEqualTo('1.0.0-0')); - t.true(version('1.0.0').isLowerThanOrEqualTo('1.0.0-beta')); - t.true(version('1.0.0').isLowerThanOrEqualTo('1.0.0')); + t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0-0')); + t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0-beta')); + t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0')); - t.false(version('1.0.0').isLowerThanOrEqualTo('1.0.1')); - t.false(version('1.0.0').isLowerThanOrEqualTo('1.1.0')); - t.false(version('1.0.0').isLowerThanOrEqualTo('2.0.0')); + t.false(new Version('1.0.0').isLowerThanOrEqualTo('1.0.1')); + t.false(new Version('1.0.0').isLowerThanOrEqualTo('1.1.0')); + t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0')); - t.false(version('1.0.0').isLowerThanOrEqualTo('2.0.0-0')); - t.false(version('1.0.0').isLowerThanOrEqualTo('2.0.0-beta')); + t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0-0')); + t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0-beta')); }); test('version.satisfies', t => { - t.true(version('2.15.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); - t.true(version('2.99.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); - t.true(version('3.10.1').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); - t.true(version('6.7.0-next.0').satisfies('<6.8.0')); - t.false(version('3.0.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); - t.false(version('3.10.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); + t.true(new Version('2.15.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); + t.true(new Version('2.99.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); + t.true(new Version('3.10.1').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); + t.true(new Version('6.7.0-next.0').satisfies('<6.8.0')); + t.false(new Version('3.0.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); + t.false(new Version('3.10.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); }); test('version.getAndValidateNewVersionFrom', t => { - t.is(version.getAndValidateNewVersionFrom('patch', '1.0.0'), '1.0.1'); + t.is(Version.getAndValidateNewVersionFrom('patch', '1.0.0'), '1.0.1'); t.throws( - () => version.getAndValidateNewVersionFrom('patch', '1'), - 'Version should be a valid semver version.' + () => Version.getAndValidateNewVersionFrom('patch', '1'), + {message: 'Version should be a valid semver version.'}, ); t.throws( - () => version.getAndValidateNewVersionFrom('lol', '1.0.0'), - `Version should be either ${version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.` + () => Version.getAndValidateNewVersionFrom('lol', '1.0.0'), + {message: `Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}, ); t.throws( - () => version.getAndValidateNewVersionFrom('1.0.0', '2.0.0'), - 'New version `1.0.0` should be higher than current version `2.0.0`' + () => Version.getAndValidateNewVersionFrom('1.0.0', '2.0.0'), + {message: 'New version `1.0.0` should be higher than current version `2.0.0`'}, ); }); From a6ce792079deec63305810766247d90d34106f2b Mon Sep 17 00:00:00 2001 From: Tommy Date: Wed, 5 Apr 2023 10:56:08 -0500 Subject: [PATCH 046/180] Get files to be packed via `npm pack --dry-run --json` (#682) --- package.json | 10 +- source/git-util.js | 4 +- source/npm/util.js | 164 ++-------------- source/ui.js | 10 +- source/util.js | 9 +- test/_utils.js | 6 + .../.github/pull_request_template.md | 9 + test/fixtures/files/dot-github/index.js | 1 + test/fixtures/files/dot-github/package.json | 5 + .../files/files-and-npmignore/package.json | 5 + .../files/files-and-npmignore/readme.md | 1 + .../files-and-npmignore/source/.npmignore | 1 + .../files/files-and-npmignore/source/bar.js | 1 + .../files/files-and-npmignore/source/foo.js | 1 + .../files-and-npmignore/source/index.d.ts | 2 + .../source/index.test-d.ts | 5 + test/fixtures/files/files-slash/index.js | 1 + test/fixtures/files/files-slash/package.json | 5 + test/fixtures/files/gitignore/dist/index.js | 2 + test/fixtures/files/gitignore/gitignore | 3 + test/fixtures/files/gitignore/index.d.ts | 1 + test/fixtures/files/gitignore/index.js | 3 + test/fixtures/files/gitignore/index.test-d.ts | 4 + test/fixtures/files/gitignore/package.json | 5 + test/fixtures/files/gitignore/readme.md | 1 + .../files/has-readme-and-license/index.js | 1 + .../files/has-readme-and-license/license.md | 1 + .../files/has-readme-and-license/package.json | 5 + .../files/has-readme-and-license/readme.md | 1 + test/fixtures/files/main/bar.js | 1 + test/fixtures/files/main/foo.js | 1 + test/fixtures/files/main/package.json | 6 + .../files/npmignore-and-gitignore/.npmignore | 2 + .../npmignore-and-gitignore/dist/index.js | 2 + .../files/npmignore-and-gitignore/gitignore | 3 + .../npmignore-and-gitignore/package.json | 4 + .../files/npmignore-and-gitignore/readme.md | 1 + .../npmignore-and-gitignore/script/build.js | 2 + .../npmignore-and-gitignore/source/index.ts | 3 + test/fixtures/files/npmignore/.npmignore | 1 + test/fixtures/files/npmignore/index.d.ts | 1 + test/fixtures/files/npmignore/index.js | 3 + test/fixtures/files/npmignore/index.test-d.ts | 4 + test/fixtures/files/npmignore/package.json | 4 + test/fixtures/files/npmignore/readme.md | 1 + test/fixtures/files/one-file/index.js | 1 + test/fixtures/files/one-file/package.json | 5 + .../files/source-and-dist-dir/dist/index.js | 2 + .../files/source-and-dist-dir/package.json | 5 + .../files/source-and-dist-dir/source/bar.js | 1 + .../files/source-and-dist-dir/source/foo.js | 1 + test/fixtures/files/source-dir/package.json | 5 + test/fixtures/files/source-dir/source/bar.js | 1 + test/fixtures/files/source-dir/source/foo.js | 1 + test/fixtures/npmignore/.hg | 1 - test/fixtures/npmignore/.npmignore | 2 - test/fixtures/npmignore/README.txt | 1 - test/fixtures/npmignore/readme.md | 1 - test/fixtures/npmignore/source/.dotfile | 0 test/fixtures/npmignore/source/ignore.txt | 1 - .../npmignore/source/pay_attention.txt | 1 - test/fixtures/npmignore/test/file.txt | 1 - test/fixtures/package/.hg | 1 - test/fixtures/package/package.json | 3 - test/fixtures/package/source/ignore.txt | 1 - .../fixtures/package/source/pay_attention.txt | 1 - test/integration.js | 184 +++++++++--------- test/new-files.js | 87 +++++++++ test/npmignore.js | 118 ----------- 69 files changed, 343 insertions(+), 388 deletions(-) create mode 100644 test/fixtures/files/dot-github/.github/pull_request_template.md create mode 100644 test/fixtures/files/dot-github/index.js create mode 100644 test/fixtures/files/dot-github/package.json create mode 100644 test/fixtures/files/files-and-npmignore/package.json create mode 100644 test/fixtures/files/files-and-npmignore/readme.md create mode 100644 test/fixtures/files/files-and-npmignore/source/.npmignore create mode 100644 test/fixtures/files/files-and-npmignore/source/bar.js create mode 100644 test/fixtures/files/files-and-npmignore/source/foo.js create mode 100644 test/fixtures/files/files-and-npmignore/source/index.d.ts create mode 100644 test/fixtures/files/files-and-npmignore/source/index.test-d.ts create mode 100644 test/fixtures/files/files-slash/index.js create mode 100644 test/fixtures/files/files-slash/package.json create mode 100644 test/fixtures/files/gitignore/dist/index.js create mode 100644 test/fixtures/files/gitignore/gitignore create mode 100644 test/fixtures/files/gitignore/index.d.ts create mode 100644 test/fixtures/files/gitignore/index.js create mode 100644 test/fixtures/files/gitignore/index.test-d.ts create mode 100644 test/fixtures/files/gitignore/package.json create mode 100644 test/fixtures/files/gitignore/readme.md create mode 100644 test/fixtures/files/has-readme-and-license/index.js create mode 100644 test/fixtures/files/has-readme-and-license/license.md create mode 100644 test/fixtures/files/has-readme-and-license/package.json create mode 100644 test/fixtures/files/has-readme-and-license/readme.md create mode 100644 test/fixtures/files/main/bar.js create mode 100644 test/fixtures/files/main/foo.js create mode 100644 test/fixtures/files/main/package.json create mode 100644 test/fixtures/files/npmignore-and-gitignore/.npmignore create mode 100644 test/fixtures/files/npmignore-and-gitignore/dist/index.js create mode 100644 test/fixtures/files/npmignore-and-gitignore/gitignore create mode 100644 test/fixtures/files/npmignore-and-gitignore/package.json create mode 100644 test/fixtures/files/npmignore-and-gitignore/readme.md create mode 100644 test/fixtures/files/npmignore-and-gitignore/script/build.js create mode 100644 test/fixtures/files/npmignore-and-gitignore/source/index.ts create mode 100644 test/fixtures/files/npmignore/.npmignore create mode 100644 test/fixtures/files/npmignore/index.d.ts create mode 100644 test/fixtures/files/npmignore/index.js create mode 100644 test/fixtures/files/npmignore/index.test-d.ts create mode 100644 test/fixtures/files/npmignore/package.json create mode 100644 test/fixtures/files/npmignore/readme.md create mode 100644 test/fixtures/files/one-file/index.js create mode 100644 test/fixtures/files/one-file/package.json create mode 100644 test/fixtures/files/source-and-dist-dir/dist/index.js create mode 100644 test/fixtures/files/source-and-dist-dir/package.json create mode 100644 test/fixtures/files/source-and-dist-dir/source/bar.js create mode 100644 test/fixtures/files/source-and-dist-dir/source/foo.js create mode 100644 test/fixtures/files/source-dir/package.json create mode 100644 test/fixtures/files/source-dir/source/bar.js create mode 100644 test/fixtures/files/source-dir/source/foo.js delete mode 100644 test/fixtures/npmignore/.hg delete mode 100644 test/fixtures/npmignore/.npmignore delete mode 100644 test/fixtures/npmignore/README.txt delete mode 100644 test/fixtures/npmignore/readme.md delete mode 100644 test/fixtures/npmignore/source/.dotfile delete mode 100644 test/fixtures/npmignore/source/ignore.txt delete mode 100644 test/fixtures/npmignore/source/pay_attention.txt delete mode 100644 test/fixtures/npmignore/test/file.txt delete mode 100644 test/fixtures/package/.hg delete mode 100644 test/fixtures/package/package.json delete mode 100644 test/fixtures/package/source/ignore.txt delete mode 100644 test/fixtures/package/source/pay_attention.txt create mode 100644 test/new-files.js delete mode 100644 test/npmignore.js diff --git a/package.json b/package.json index 219d79d7..920aa6ee 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "yarn": ">=1.7.0" }, "scripts": { - "test": "xo && ava && ava test/integration.js --no-worker-threads" + "test": "xo && ava" }, "files": [ "source" @@ -60,6 +60,7 @@ "ow": "^1.1.1", "p-memoize": "^7.1.1", "p-timeout": "^6.1.1", + "path-exists": "^5.0.0", "pkg-dir": "^7.0.0", "read-pkg-up": "^9.1.0", "rxjs": "^7.8.0", @@ -71,18 +72,17 @@ "devDependencies": { "ava": "^5.2.0", "common-tags": "^1.8.2", - "esmock": "^2.2.0", + "esmock": "^2.2.1", "fs-extra": "^11.1.1", "sinon": "^15.0.3", + "tempy": "^3.0.0", + "write-pkg": "^5.1.0", "xo": "^0.53.1" }, "ava": { "environmentVariables": { "FORCE_HYPERLINK": "1" }, - "files": [ - "!test/integration.js" - ], "nodeArguments": [ "--loader=esmock" ] diff --git a/source/git-util.js b/source/git-util.js index 917655ea..2f76d561 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -2,7 +2,7 @@ import path from 'node:path'; import {execa} from 'execa'; import escapeStringRegexp from 'escape-string-regexp'; import ignoreWalker from 'ignore-walk'; -import {packageDirectorySync} from 'pkg-dir'; +import {packageDirectory} from 'pkg-dir'; import Version from './version.js'; export const latestTag = async () => { @@ -27,7 +27,7 @@ export const newFilesSinceLastRelease = async () => { } catch { // Get all files under version control return ignoreWalker({ - path: packageDirectorySync(), + path: await packageDirectory(), ignoreFiles: ['.gitignore'], }); } diff --git a/source/npm/util.js b/source/npm/util.js index 0d6582a5..7cf46998 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -1,39 +1,14 @@ -import fs from 'node:fs'; import path from 'node:path'; +import {pathExists} from 'path-exists'; import {execa} from 'execa'; import pTimeout from 'p-timeout'; import ow from 'ow'; import npmName from 'npm-name'; import chalk from 'chalk'; -import {packageDirectorySync} from 'pkg-dir'; -import ignoreWalker from 'ignore-walk'; -import minimatch from 'minimatch'; +import {packageDirectory} from 'pkg-dir'; import semver from 'semver'; import Version from '../version.js'; -// According to https://docs.npmjs.com/files/package.json#files -// npm's default behavior is to ignore these files. -const filesIgnoredByDefault = [ - '.*.swp', - '.npmignore', - '.gitignore', - '._*', - '.DS_Store', - '.hg', - '.npmrc', - '.lock-wscript', - '.svn', - '.wafpickle-N', - '*.orig', - 'config.gypi', - 'CVS', - 'node_modules/**/*', - 'npm-debug.log', - 'package-lock.json', - '.git/**/*', - '.git', -]; - export const checkConnection = () => pTimeout( (async () => { try { @@ -154,137 +129,24 @@ export const verifyRecentNpmVersion = async () => { Version.verifyRequirementSatisfied('npm', npmVersion); }; -export const checkIgnoreStrategy = ({files}) => { - if (!files && !npmignoreExistsInPackageRootDir()) { +const npmignoreExistsInPackageRootDir = async () => { + const rootDir = await packageDirectory(); + return pathExists(path.resolve(rootDir, '.npmignore')); +}; + +export const checkIgnoreStrategy = async ({files}) => { + if (!files && !(await npmignoreExistsInPackageRootDir())) { console.log(` \n${chalk.bold.yellow('Warning:')} No ${chalk.bold.cyan('files')} field specified in ${chalk.bold.magenta('package.json')} nor is a ${chalk.bold.magenta('.npmignore')} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. `); } }; -function npmignoreExistsInPackageRootDir() { - const rootDir = packageDirectorySync(); - return fs.existsSync(path.resolve(rootDir, '.npmignore')); -} - -function excludeGitAndNodeModulesPaths(singlePath) { - return !singlePath.startsWith('.git/') && !singlePath.startsWith('node_modules/'); -} - -async function getFilesIgnoredByDotnpmignore(pkg, fileList) { - let allowList = await ignoreWalker({ - path: packageDirectorySync(), - ignoreFiles: ['.npmignore'], - }); - allowList = allowList.filter(singlePath => excludeGitAndNodeModulesPaths(singlePath)); - return fileList.filter(minimatch.filter(getIgnoredFilesGlob(allowList, pkg.directories), {matchBase: true, dot: true})); -} - -function filterFileList(globArray, fileList) { - if (globArray.length === 0) { - return []; - } - - const globString = globArray.length > 1 ? `{${globArray.filter(singlePath => excludeGitAndNodeModulesPaths(singlePath))}}` : globArray[0]; - return fileList.filter(minimatch.filter(globString, {matchBase: true, dot: true})); // eslint-disable-line unicorn/no-array-callback-reference, unicorn/no-array-method-this-argument -} - -async function getFilesIncludedByDotnpmignore(pkg, fileList) { - const allowList = await ignoreWalker({ - path: packageDirectorySync(), - ignoreFiles: ['.npmignore'], - }); - return filterFileList(allowList, fileList); -} - -function getFilesNotIncludedInFilesProperty(pkg, fileList) { - const globArrayForFilesAndDirectories = [...pkg.files]; - const rootDir = packageDirectorySync(); - for (const glob of pkg.files) { - try { - if (fs.statSync(path.resolve(rootDir, glob)).isDirectory()) { - globArrayForFilesAndDirectories.push(`${glob}/**/*`); - } - } catch {} - } - - const result = fileList.filter(minimatch.filter(getIgnoredFilesGlob(globArrayForFilesAndDirectories, pkg.directories), {matchBase: true, dot: true})); - return result.filter(minimatch.filter(getDefaultIncludedFilesGlob(pkg.main), {nocase: true, matchBase: true})); -} - -function getFilesIncludedInFilesProperty(pkg, fileList) { - const globArrayForFilesAndDirectories = [...pkg.files]; - const rootDir = packageDirectorySync(); - for (const glob of pkg.files) { - try { - if (fs.statSync(path.resolve(rootDir, glob)).isDirectory()) { - globArrayForFilesAndDirectories.push(`${glob}/**/*`); - } - } catch {} - } - - return filterFileList(globArrayForFilesAndDirectories, fileList); -} - -function getDefaultIncludedFilesGlob(mainFile) { - // According to https://docs.npmjs.com/files/package.json#files - // npm's default behavior is to always include these files. - const filesAlwaysIncluded = [ - 'package.json', - 'README*', - 'CHANGES*', - 'CHANGELOG*', - 'HISTORY*', - 'LICENSE*', - 'LICENCE*', - 'NOTICE*', - ]; - if (mainFile) { - filesAlwaysIncluded.push(mainFile); - } - - return `!{${filesAlwaysIncluded}}`; -} - -function getIgnoredFilesGlob(globArrayFromFilesProperty, packageDirectories) { - // Test files are assumed not to be part of the package - let testDirectoriesGlob = ''; - if (packageDirectories && Array.isArray(packageDirectories.test)) { - testDirectoriesGlob = packageDirectories.test.join(','); - } else if (packageDirectories && typeof packageDirectories.test === 'string') { - testDirectoriesGlob = packageDirectories.test; - } else { - // Fallback to `test` directory - testDirectoriesGlob = 'test/**/*'; - } - - return `!{${globArrayFromFilesProperty.join(',')},${filesIgnoredByDefault.join(',')},${testDirectoriesGlob}}`; -} - -// Get all files which will be ignored by either `.npmignore` or the `files` property in `package.json` (if defined). -export const getNewAndUnpublishedFiles = async (pkg, newFiles = []) => { - if (pkg.files) { - return getFilesNotIncludedInFilesProperty(pkg, newFiles); - } - - if (npmignoreExistsInPackageRootDir()) { - return getFilesIgnoredByDotnpmignore(pkg, newFiles); - } - - return []; -}; - -export const getFirstTimePublishedFiles = async (pkg, newFiles = []) => { - let result; - if (pkg.files) { - result = getFilesIncludedInFilesProperty(pkg, newFiles); - } else if (npmignoreExistsInPackageRootDir()) { - result = await getFilesIncludedByDotnpmignore(pkg, newFiles); - } else { - result = newFiles; - } +export const getFilesToBePacked = async () => { + const {stdout} = await execa('npm', ['pack', '--dry-run', '--json'], {cwd: await packageDirectory()}); - return result.filter(minimatch.filter(`!{${filesIgnoredByDefault}}`, {matchBase: true, dot: true})).filter(minimatch.filter(getDefaultIncludedFilesGlob(pkg.main), {nocase: true, matchBase: true})); + const {files} = JSON.parse(stdout).at(0); + return files.map(file => file.path); }; export const getRegistryUrl = async (pkgManager, pkg) => { diff --git a/source/ui.js b/source/ui.js index 3cbfb213..389cf53e 100644 --- a/source/ui.js +++ b/source/ui.js @@ -6,7 +6,7 @@ import isScoped from 'is-scoped'; import isInteractive from 'is-interactive'; import * as util from './util.js'; import * as git from './git-util.js'; -import {prereleaseTags, checkIgnoreStrategy, getRegistryUrl, isExternalRegistry} from './npm/util.js'; +import * as npmUtil from './npm/util.js'; import Version from './version.js'; import prettyVersionDiff from './pretty-version-diff.js'; @@ -126,11 +126,11 @@ const ui = async (options, {pkg, pkgPath}) => { const extraBaseUrls = ['gitlab.com']; const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); const pkgManager = options.yarn ? 'yarn' : 'npm'; - const registryUrl = await getRegistryUrl(pkgManager, pkg); + const registryUrl = await npmUtil.getRegistryUrl(pkgManager, pkg); const releaseBranch = options.branch; if (options.runPublish) { - checkIgnoreStrategy(pkg); + await npmUtil.checkIgnoreStrategy(pkg); const answerIgnoredFiles = await checkNewFilesAndDependencies(pkg, pkgPath); if (!answerIgnoredFiles) { @@ -253,7 +253,7 @@ const ui = async (options, {pkg, pkgPath}) => { message: 'How should this pre-release version be tagged in npm?', when: answers => options.runPublish && (Version.isPrereleaseOrIncrement(answers.customVersion) || Version.isPrereleaseOrIncrement(answers.version)) && !options.tag, async choices() { - const existingPrereleaseTags = await prereleaseTags(pkg.name); + const existingPrereleaseTags = await npmUtil.prereleaseTags(pkg.name); return [ ...existingPrereleaseTags, @@ -283,7 +283,7 @@ const ui = async (options, {pkg, pkgPath}) => { }, publishScoped: { type: 'confirm', - when: isScoped(pkg.name) && options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !isExternalRegistry(pkg), + when: isScoped(pkg.name) && options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !npmUtil.isExternalRegistry(pkg), message: `This scoped repo ${chalk.bold.magenta(pkg.name)} hasn't been published. Do you want to publish it publicly?`, default: false, }, diff --git a/source/util.js b/source/util.js index b76a4080..88960b55 100644 --- a/source/util.js +++ b/source/util.js @@ -72,9 +72,14 @@ export const getTagVersionPrefix = pMemoize(async options => { export const joinList = list => chalk.reset(list.map(item => `- ${item}`).join('\n')); -export const getNewFiles = async pkg => { +export const getNewFiles = async () => { const listNewFiles = await gitUtil.newFilesSinceLastRelease(); - return {unpublished: await npmUtil.getNewAndUnpublishedFiles(pkg, listNewFiles), firstTime: await npmUtil.getFirstTimePublishedFiles(pkg, listNewFiles)}; + const listPkgFiles = await npmUtil.getFilesToBePacked(); + + return { + unpublished: listNewFiles.filter(file => !listPkgFiles.includes(file) && !file.startsWith('.git')), + firstTime: listNewFiles.filter(file => listPkgFiles.includes(file)), + }; }; export const getNewDependencies = async (newPkg, pkgPath) => { diff --git a/test/_utils.js b/test/_utils.js index 1f9a47f3..f91da530 100644 --- a/test/_utils.js +++ b/test/_utils.js @@ -48,3 +48,9 @@ export const assertTaskDisabled = (t, taskTitle) => { export const assertTaskDoesntExist = (t, taskTitle) => { t.true(SilentRenderer.tasks.every(task => task.title !== taskTitle), `'${taskTitle}' exists!`); }; + +export const runIfExists = async (func, ...args) => { + if (typeof func === 'function') { + await func(...args); + } +}; diff --git a/test/fixtures/files/dot-github/.github/pull_request_template.md b/test/fixtures/files/dot-github/.github/pull_request_template.md new file mode 100644 index 00000000..5c30d3f2 --- /dev/null +++ b/test/fixtures/files/dot-github/.github/pull_request_template.md @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/files/dot-github/index.js b/test/fixtures/files/dot-github/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/dot-github/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/dot-github/package.json b/test/fixtures/files/dot-github/package.json new file mode 100644 index 00000000..d08c2333 --- /dev/null +++ b/test/fixtures/files/dot-github/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"] +} diff --git a/test/fixtures/files/files-and-npmignore/package.json b/test/fixtures/files/files-and-npmignore/package.json new file mode 100644 index 00000000..aa65336d --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["source"] +} diff --git a/test/fixtures/files/files-and-npmignore/readme.md b/test/fixtures/files/files-and-npmignore/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/files-and-npmignore/source/.npmignore b/test/fixtures/files/files-and-npmignore/source/.npmignore new file mode 100644 index 00000000..b0b0549d --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/.npmignore @@ -0,0 +1 @@ +index.test-d.ts diff --git a/test/fixtures/files/files-and-npmignore/source/bar.js b/test/fixtures/files/files-and-npmignore/source/bar.js new file mode 100644 index 00000000..8cc7aa3e --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/bar.js @@ -0,0 +1 @@ +console.log('bar'); diff --git a/test/fixtures/files/files-and-npmignore/source/foo.js b/test/fixtures/files/files-and-npmignore/source/foo.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/foo.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/files-and-npmignore/source/index.d.ts b/test/fixtures/files/files-and-npmignore/source/index.d.ts new file mode 100644 index 00000000..ce96e9a6 --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/index.d.ts @@ -0,0 +1,2 @@ +export function foo(): string; +export function bar(): string; diff --git a/test/fixtures/files/files-and-npmignore/source/index.test-d.ts b/test/fixtures/files/files-and-npmignore/source/index.test-d.ts new file mode 100644 index 00000000..448777bb --- /dev/null +++ b/test/fixtures/files/files-and-npmignore/source/index.test-d.ts @@ -0,0 +1,5 @@ +import {expectType} from 'tsd'; +import {foo, bar} from '.'; + +expectType(foo()); +expectType(bar()); diff --git a/test/fixtures/files/files-slash/index.js b/test/fixtures/files/files-slash/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/files-slash/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/files-slash/package.json b/test/fixtures/files/files-slash/package.json new file mode 100644 index 00000000..850e9c9f --- /dev/null +++ b/test/fixtures/files/files-slash/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["/index.js"] +} diff --git a/test/fixtures/files/gitignore/dist/index.js b/test/fixtures/files/gitignore/dist/index.js new file mode 100644 index 00000000..714c3081 --- /dev/null +++ b/test/fixtures/files/gitignore/dist/index.js @@ -0,0 +1,2 @@ +console.log('foo'); +console.log('bar'); diff --git a/test/fixtures/files/gitignore/gitignore b/test/fixtures/files/gitignore/gitignore new file mode 100644 index 00000000..a01644f5 --- /dev/null +++ b/test/fixtures/files/gitignore/gitignore @@ -0,0 +1,3 @@ +# This file is renamed to `.gitignore` in the test +# This is not named `.gitignore` to allow `dist/` to be committed +dist diff --git a/test/fixtures/files/gitignore/index.d.ts b/test/fixtures/files/gitignore/index.d.ts new file mode 100644 index 00000000..07134df8 --- /dev/null +++ b/test/fixtures/files/gitignore/index.d.ts @@ -0,0 +1 @@ +export default function foo(): string; diff --git a/test/fixtures/files/gitignore/index.js b/test/fixtures/files/gitignore/index.js new file mode 100644 index 00000000..50862df2 --- /dev/null +++ b/test/fixtures/files/gitignore/index.js @@ -0,0 +1,3 @@ +export default function foo() { + return 'bar'; +} diff --git a/test/fixtures/files/gitignore/index.test-d.ts b/test/fixtures/files/gitignore/index.test-d.ts new file mode 100644 index 00000000..650c167e --- /dev/null +++ b/test/fixtures/files/gitignore/index.test-d.ts @@ -0,0 +1,4 @@ +import {expectType} from 'tsd'; +import foo from '.'; + +expectType(foo()); diff --git a/test/fixtures/files/gitignore/package.json b/test/fixtures/files/gitignore/package.json new file mode 100644 index 00000000..745fec5b --- /dev/null +++ b/test/fixtures/files/gitignore/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["dist"] +} diff --git a/test/fixtures/files/gitignore/readme.md b/test/fixtures/files/gitignore/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/gitignore/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/has-readme-and-license/index.js b/test/fixtures/files/has-readme-and-license/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/has-readme-and-license/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/has-readme-and-license/license.md b/test/fixtures/files/has-readme-and-license/license.md new file mode 100644 index 00000000..a22a2da2 --- /dev/null +++ b/test/fixtures/files/has-readme-and-license/license.md @@ -0,0 +1 @@ +MIT diff --git a/test/fixtures/files/has-readme-and-license/package.json b/test/fixtures/files/has-readme-and-license/package.json new file mode 100644 index 00000000..d08c2333 --- /dev/null +++ b/test/fixtures/files/has-readme-and-license/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"] +} diff --git a/test/fixtures/files/has-readme-and-license/readme.md b/test/fixtures/files/has-readme-and-license/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/has-readme-and-license/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/main/bar.js b/test/fixtures/files/main/bar.js new file mode 100644 index 00000000..8cc7aa3e --- /dev/null +++ b/test/fixtures/files/main/bar.js @@ -0,0 +1 @@ +console.log('bar'); diff --git a/test/fixtures/files/main/foo.js b/test/fixtures/files/main/foo.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/main/foo.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/main/package.json b/test/fixtures/files/main/package.json new file mode 100644 index 00000000..10a4cbd9 --- /dev/null +++ b/test/fixtures/files/main/package.json @@ -0,0 +1,6 @@ +{ + "name": "foo", + "version": "0.0.0", + "main": "foo.js", + "files": ["bar.js"] +} diff --git a/test/fixtures/files/npmignore-and-gitignore/.npmignore b/test/fixtures/files/npmignore-and-gitignore/.npmignore new file mode 100644 index 00000000..7dbdd130 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/.npmignore @@ -0,0 +1,2 @@ +script/ +source/ diff --git a/test/fixtures/files/npmignore-and-gitignore/dist/index.js b/test/fixtures/files/npmignore-and-gitignore/dist/index.js new file mode 100644 index 00000000..714c3081 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/dist/index.js @@ -0,0 +1,2 @@ +console.log('foo'); +console.log('bar'); diff --git a/test/fixtures/files/npmignore-and-gitignore/gitignore b/test/fixtures/files/npmignore-and-gitignore/gitignore new file mode 100644 index 00000000..a01644f5 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/gitignore @@ -0,0 +1,3 @@ +# This file is renamed to `.gitignore` in the test +# This is not named `.gitignore` to allow `dist/` to be committed +dist diff --git a/test/fixtures/files/npmignore-and-gitignore/package.json b/test/fixtures/files/npmignore-and-gitignore/package.json new file mode 100644 index 00000000..3538ae77 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "0.0.0" +} diff --git a/test/fixtures/files/npmignore-and-gitignore/readme.md b/test/fixtures/files/npmignore-and-gitignore/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/npmignore-and-gitignore/script/build.js b/test/fixtures/files/npmignore-and-gitignore/script/build.js new file mode 100644 index 00000000..8a2c0921 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/script/build.js @@ -0,0 +1,2 @@ +/* eslint-disable unicorn/no-empty-file */ +// ... yada yada yada diff --git a/test/fixtures/files/npmignore-and-gitignore/source/index.ts b/test/fixtures/files/npmignore-and-gitignore/source/index.ts new file mode 100644 index 00000000..50862df2 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/source/index.ts @@ -0,0 +1,3 @@ +export default function foo() { + return 'bar'; +} diff --git a/test/fixtures/files/npmignore/.npmignore b/test/fixtures/files/npmignore/.npmignore new file mode 100644 index 00000000..b0b0549d --- /dev/null +++ b/test/fixtures/files/npmignore/.npmignore @@ -0,0 +1 @@ +index.test-d.ts diff --git a/test/fixtures/files/npmignore/index.d.ts b/test/fixtures/files/npmignore/index.d.ts new file mode 100644 index 00000000..07134df8 --- /dev/null +++ b/test/fixtures/files/npmignore/index.d.ts @@ -0,0 +1 @@ +export default function foo(): string; diff --git a/test/fixtures/files/npmignore/index.js b/test/fixtures/files/npmignore/index.js new file mode 100644 index 00000000..50862df2 --- /dev/null +++ b/test/fixtures/files/npmignore/index.js @@ -0,0 +1,3 @@ +export default function foo() { + return 'bar'; +} diff --git a/test/fixtures/files/npmignore/index.test-d.ts b/test/fixtures/files/npmignore/index.test-d.ts new file mode 100644 index 00000000..650c167e --- /dev/null +++ b/test/fixtures/files/npmignore/index.test-d.ts @@ -0,0 +1,4 @@ +import {expectType} from 'tsd'; +import foo from '.'; + +expectType(foo()); diff --git a/test/fixtures/files/npmignore/package.json b/test/fixtures/files/npmignore/package.json new file mode 100644 index 00000000..3538ae77 --- /dev/null +++ b/test/fixtures/files/npmignore/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "version": "0.0.0" +} diff --git a/test/fixtures/files/npmignore/readme.md b/test/fixtures/files/npmignore/readme.md new file mode 100644 index 00000000..7635c78e --- /dev/null +++ b/test/fixtures/files/npmignore/readme.md @@ -0,0 +1 @@ +# Foo diff --git a/test/fixtures/files/one-file/index.js b/test/fixtures/files/one-file/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/one-file/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/one-file/package.json b/test/fixtures/files/one-file/package.json new file mode 100644 index 00000000..d08c2333 --- /dev/null +++ b/test/fixtures/files/one-file/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"] +} diff --git a/test/fixtures/files/source-and-dist-dir/dist/index.js b/test/fixtures/files/source-and-dist-dir/dist/index.js new file mode 100644 index 00000000..714c3081 --- /dev/null +++ b/test/fixtures/files/source-and-dist-dir/dist/index.js @@ -0,0 +1,2 @@ +console.log('foo'); +console.log('bar'); diff --git a/test/fixtures/files/source-and-dist-dir/package.json b/test/fixtures/files/source-and-dist-dir/package.json new file mode 100644 index 00000000..aa65336d --- /dev/null +++ b/test/fixtures/files/source-and-dist-dir/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["source"] +} diff --git a/test/fixtures/files/source-and-dist-dir/source/bar.js b/test/fixtures/files/source-and-dist-dir/source/bar.js new file mode 100644 index 00000000..8cc7aa3e --- /dev/null +++ b/test/fixtures/files/source-and-dist-dir/source/bar.js @@ -0,0 +1 @@ +console.log('bar'); diff --git a/test/fixtures/files/source-and-dist-dir/source/foo.js b/test/fixtures/files/source-and-dist-dir/source/foo.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/source-and-dist-dir/source/foo.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/source-dir/package.json b/test/fixtures/files/source-dir/package.json new file mode 100644 index 00000000..aa65336d --- /dev/null +++ b/test/fixtures/files/source-dir/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["source"] +} diff --git a/test/fixtures/files/source-dir/source/bar.js b/test/fixtures/files/source-dir/source/bar.js new file mode 100644 index 00000000..8cc7aa3e --- /dev/null +++ b/test/fixtures/files/source-dir/source/bar.js @@ -0,0 +1 @@ +console.log('bar'); diff --git a/test/fixtures/files/source-dir/source/foo.js b/test/fixtures/files/source-dir/source/foo.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/source-dir/source/foo.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/npmignore/.hg b/test/fixtures/npmignore/.hg deleted file mode 100644 index 3f06d3e2..00000000 --- a/test/fixtures/npmignore/.hg +++ /dev/null @@ -1 +0,0 @@ -should be ignored by default diff --git a/test/fixtures/npmignore/.npmignore b/test/fixtures/npmignore/.npmignore deleted file mode 100644 index 501c21cd..00000000 --- a/test/fixtures/npmignore/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -ignore.txt -test diff --git a/test/fixtures/npmignore/README.txt b/test/fixtures/npmignore/README.txt deleted file mode 100644 index 5086e7b4..00000000 --- a/test/fixtures/npmignore/README.txt +++ /dev/null @@ -1 +0,0 @@ -File is always included in package. diff --git a/test/fixtures/npmignore/readme.md b/test/fixtures/npmignore/readme.md deleted file mode 100644 index 5086e7b4..00000000 --- a/test/fixtures/npmignore/readme.md +++ /dev/null @@ -1 +0,0 @@ -File is always included in package. diff --git a/test/fixtures/npmignore/source/.dotfile b/test/fixtures/npmignore/source/.dotfile deleted file mode 100644 index e69de29b..00000000 diff --git a/test/fixtures/npmignore/source/ignore.txt b/test/fixtures/npmignore/source/ignore.txt deleted file mode 100644 index 26ef7633..00000000 --- a/test/fixtures/npmignore/source/ignore.txt +++ /dev/null @@ -1 +0,0 @@ -Ignore this file diff --git a/test/fixtures/npmignore/source/pay_attention.txt b/test/fixtures/npmignore/source/pay_attention.txt deleted file mode 100644 index 01a573f9..00000000 --- a/test/fixtures/npmignore/source/pay_attention.txt +++ /dev/null @@ -1 +0,0 @@ -File is excluded from .npmignore diff --git a/test/fixtures/npmignore/test/file.txt b/test/fixtures/npmignore/test/file.txt deleted file mode 100644 index 375fb8ee..00000000 --- a/test/fixtures/npmignore/test/file.txt +++ /dev/null @@ -1 +0,0 @@ -ignore this file diff --git a/test/fixtures/package/.hg b/test/fixtures/package/.hg deleted file mode 100644 index 3f06d3e2..00000000 --- a/test/fixtures/package/.hg +++ /dev/null @@ -1 +0,0 @@ -should be ignored by default diff --git a/test/fixtures/package/package.json b/test/fixtures/package/package.json deleted file mode 100644 index b2861deb..00000000 --- a/test/fixtures/package/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "files": ["pay_attention.txt"] -} diff --git a/test/fixtures/package/source/ignore.txt b/test/fixtures/package/source/ignore.txt deleted file mode 100644 index 40f91d34..00000000 --- a/test/fixtures/package/source/ignore.txt +++ /dev/null @@ -1 +0,0 @@ -File is excluded from package.json diff --git a/test/fixtures/package/source/pay_attention.txt b/test/fixtures/package/source/pay_attention.txt deleted file mode 100644 index e5f3e01f..00000000 --- a/test/fixtures/package/source/pay_attention.txt +++ /dev/null @@ -1 +0,0 @@ -File in included in package.json diff --git a/test/integration.js b/test/integration.js index 83d43d6e..25abc62b 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1,104 +1,106 @@ -/* eslint-disable ava/no-ignored-test-files */ -import process from 'node:process'; import path from 'node:path'; import fs from 'fs-extra'; import test from 'ava'; -import {$} from 'execa'; -import {deleteAsync} from 'del'; -import * as gitUtil from '../source/git-util.js'; -import * as util from '../source/util.js'; - -test.before(async t => { - await fs.emptyDir('integration'); - process.chdir('integration'); - - await $`git init`; - await t.throwsAsync(gitUtil.latestTag(), undefined, 'prerequisites not met: repository should not contain any tags'); - - await fs.createFile('temp'); - await $`git add .`; - await $`git commit -m 'init'`; - await deleteAsync('temp'); +import esmock from 'esmock'; +import {$, execa} from 'execa'; +import {temporaryDirectoryTask} from 'tempy'; +import {writePackage} from 'write-pkg'; + +const createEmptyGitRepo = async ($$, temporaryDir) => { + await $$`git init`; + + // `git tag` needs an initial commit + await fs.createFile(path.resolve(temporaryDir, 'temp')); + await $$`git add temp`; + await $$`git commit -m "init1"`; + await $$`git rm temp`; + await $$`git commit -m "init2"`; +}; + +const createIntegrationTest = async (t, assertions) => { + await temporaryDirectoryTask(async temporaryDir => { + const $$ = $({cwd: temporaryDir}); + + await createEmptyGitRepo($$, temporaryDir); + + t.context.createFile = async file => fs.createFile(path.resolve(temporaryDir, file)); + await assertions($$, temporaryDir); + }); +}; + +test('main', async t => { + await createIntegrationTest(t, async $$ => { + await t.context.createFile('testFile'); + + const {stdout} = await $$`git status -u`; + + t.true( + stdout.includes('Untracked files') && stdout.includes('testFile'), + 'File wasn\'t created properly!', + ); + }); }); -test.after.always(async () => { - process.chdir('..'); - await deleteAsync('integration'); +const createFixture = test.macro(async (t, pkgFiles, commands, {unpublished, firstTime}) => { + await createIntegrationTest(t, async ($$, temporaryDir) => { + /** @type {import('../source/util.js')} */ + const util = await esmock('../source/util.js', {}, { + 'node:process': {cwd: () => temporaryDir}, + execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + 'pkg-dir': {packageDirectory: async () => temporaryDir}, + }); + + await commands(t, $$, temporaryDir); + + await writePackage(temporaryDir, { + name: 'foo', + version: '0.0.0', + ...pkgFiles.length > 0 ? {files: pkgFiles} : {}, + }); + + t.deepEqual( + await util.getNewFiles(), + {unpublished, firstTime}, + ); + }); }); -test.afterEach.always(async t => { - if (typeof t.context.teardown === 'function') { - await t.context.teardown(); - } -}); - -test.serial('files to package with tags added', async t => { - await $`git tag v0.0.0`; - await fs.createFile('new'); - await fs.createFile('index.js'); - await $`git add new index.js`; - await $`git commit -m "added"`; - - t.context.teardown = async () => { - await $`git rm new`; - await $`git rm index.js`; - await $`git tag -d v0.0.0`; - await $`git commit -m "deleted"`; - }; - - t.deepEqual( - await util.getNewFiles({files: ['*.js']}), - {unpublished: ['new'], firstTime: ['index.js']}, - ); -}); +test('files to package with tags added', createFixture, ['*.js'], async (t, $$) => { + await $$`git tag v0.0.0`; + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, {unpublished: ['new'], firstTime: ['index.js']}); -test.serial.failing('file `new` to package without tags added', async t => { - await fs.createFile('new'); - await fs.createFile('index.js'); - - t.context.teardown = async () => { - await deleteAsync(['new', 'index.js']); - }; - - t.deepEqual( - await util.getNewFiles({files: ['index.js']}), - {unpublished: ['new'], firstTime: ['index.js']}, - ); -}); +test('file `new` to package without tags added', createFixture, ['index.js'], async t => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); +}, {unpublished: ['new'], firstTime: ['index.js', 'package.json']}); -test.serial('files with long pathnames added', async t => { +(() => { // Wrapper to have constants with macro const longPath = path.join('veryLonggggggDirectoryName', 'veryLonggggggDirectoryName'); const filePath1 = path.join(longPath, 'file1'); const filePath2 = path.join(longPath, 'file2'); - await $`git tag v0.0.0`; - await fs.mkdir(longPath, {recursive: true}); - await fs.createFile(filePath1); - await fs.createFile(filePath2); - await $`git add ${filePath1} ${filePath2}`; - await $`git commit -m "added"`; - - t.context.teardown = async () => { - await $`git rm -r ${longPath}`; - await $`git tag -d v0.0.0`; - await $`git commit -m "deleted"`; - }; - - t.deepEqual( - await util.getNewFiles({files: ['*.js']}), - {unpublished: [filePath1, filePath2], firstTime: []}, - ); -}); - -test.serial('no new files added', async t => { - await $`git tag v0.0.0`; - - t.context.teardown = async () => { - await $`git tag -d v0.0.0`; - }; - - t.deepEqual( - await util.getNewFiles({files: ['*.js']}), - {unpublished: [], firstTime: []}, - ); -}); + test('files with long pathnames added', createFixture, ['*.js'], async (t, $$) => { + await $$`git tag v0.0.0`; + await t.context.createFile(filePath1); + await t.context.createFile(filePath2); + await $$`git add -A`; + await $$`git commit -m "added"`; + }, {unpublished: [filePath1, filePath2], firstTime: []}); +})(); + +test('no new files added', createFixture, [], async (_t, $$) => { + await $$`git tag v0.0.0`; +}, {unpublished: [], firstTime: []}); + +test('ignores .git and .github files', createFixture, ['*.js'], async (t, $$) => { + await $$`git tag v0.0.0`; + await t.context.createFile('.github/workflows/main.yml'); + await t.context.createFile('.github/pull_request_template'); + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, {unpublished: [], firstTime: ['index.js']}); diff --git a/test/new-files.js b/test/new-files.js new file mode 100644 index 00000000..66da1f0e --- /dev/null +++ b/test/new-files.js @@ -0,0 +1,87 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import test from 'ava'; +import esmock from 'esmock'; +import {runIfExists} from './_utils.js'; + +const getFixture = name => path.resolve('test', 'fixtures', 'files', name); + +const mockPkgDir = test.macro(async (t, fixture, expectedFiles, {before, after} = {}) => { + const fixtureDir = getFixture(fixture); + + await runIfExists(before, fixtureDir); + t.teardown(async () => runIfExists(after, fixtureDir)); + + const npmUtil = await esmock('../source/npm/util.js', { + 'pkg-dir': {packageDirectory: async () => fixtureDir}, + }); + + const files = await npmUtil.getFilesToBePacked(); + t.deepEqual(files.sort(), [...expectedFiles, 'package.json'].sort(), 'Files different from expectations!'); +}); + +test('package.json files field - one file', mockPkgDir, 'one-file', [ + 'index.js', +]); + +test('package.json files field - source dir', mockPkgDir, 'source-dir', [ + 'source/foo.js', + 'source/bar.js', +]); + +test('package.json files field - source and dist dirs', mockPkgDir, 'source-and-dist-dir', [ + 'source/foo.js', + 'source/bar.js', +]); + +test('package.json files field - leading slash', mockPkgDir, 'files-slash', [ + 'index.js', +]); + +test('package.json files field - has readme and license', mockPkgDir, 'has-readme-and-license', [ + 'readme.md', + 'license.md', + 'index.js', +]); + +test('npmignore', mockPkgDir, 'npmignore', [ + 'readme.md', + 'index.js', + 'index.d.ts', +]); + +test('package.json files field and npmignore', mockPkgDir, 'files-and-npmignore', [ + 'readme.md', + 'source/foo.js', + 'source/bar.js', + 'source/index.d.ts', +]); + +const renameDotGitignore = { + async before(fixtureDir) { + await fs.rename(`${fixtureDir}/gitignore`, `${fixtureDir}/.gitignore`); + }, + async after(fixtureDir) { + await fs.rename(`${fixtureDir}/.gitignore`, `${fixtureDir}/gitignore`); + }, +}; + +test('package.json files field and gitignore', mockPkgDir, 'gitignore', [ + 'readme.md', + 'dist/index.js', +], renameDotGitignore); + +test('npmignore and gitignore', mockPkgDir, 'npmignore-and-gitignore', [ + 'readme.md', + 'dist/index.js', +], renameDotGitignore); + +test('package.json main field not in files field', mockPkgDir, 'main', [ + 'foo.js', + 'bar.js', +]); + +test('doesn\'t show files in .github', mockPkgDir, 'dot-github', [ + 'index.js', +]); + diff --git a/test/npmignore.js b/test/npmignore.js deleted file mode 100644 index 4f02492a..00000000 --- a/test/npmignore.js +++ /dev/null @@ -1,118 +0,0 @@ -import path from 'node:path'; -import test from 'ava'; -import esmock from 'esmock'; - -const newFiles = [ - 'source/ignore.txt', - 'source/pay_attention.txt', - '.hg', - 'test/file.txt', - 'readme.md', - 'README.txt', -]; - -const mockPkgDir = test.macro(async (t, paths, impl) => { - const testedModule = await esmock('../source/npm/util.js', { - 'pkg-dir': {packageDirectorySync: () => path.resolve(...paths)}, - }); - - await impl(t, testedModule); -}); - -test.serial('ignored files using file-attribute in package.json with one file', mockPkgDir, ['test', 'fixtures', 'package'], - async (t, {getNewAndUnpublishedFiles}) => { - t.deepEqual(await getNewAndUnpublishedFiles({files: ['pay_attention.txt']}, newFiles), ['source/ignore.txt']); - }, -); - -test.serial('ignored file using file-attribute in package.json with directory', mockPkgDir, ['test', 'fixtures', 'package'], - async (t, {getNewAndUnpublishedFiles}) => { - t.deepEqual(await getNewAndUnpublishedFiles({files: ['source']}, newFiles), []); - }, -); - -test.serial('ignored test files using files attribute and directory structure in package.json', mockPkgDir, ['test', 'fixtures', 'package'], - async (t, {getNewAndUnpublishedFiles}) => { - t.deepEqual(await getNewAndUnpublishedFiles({files: ['source'], directories: {test: 'test-tap'}}, newFiles), ['test/file.txt']); - t.deepEqual(await getNewAndUnpublishedFiles({files: ['source'], directories: {test: ['test-tap']}}, newFiles), ['test/file.txt']); - }, -); - -test.serial('ignored files using .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], - async (t, {getNewAndUnpublishedFiles}) => { - t.deepEqual(await getNewAndUnpublishedFiles({name: 'npmignore'}, newFiles), ['source/ignore.txt']); - }, -); - -test.serial('ignored test files using files attribute and .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], - async (t, {getNewAndUnpublishedFiles}) => { - t.deepEqual(await getNewAndUnpublishedFiles({directories: {test: 'test-tap'}}, newFiles), ['source/ignore.txt', 'test/file.txt']); - t.deepEqual(await getNewAndUnpublishedFiles({directories: {test: ['test-tap']}}, newFiles), ['source/ignore.txt', 'test/file.txt']); - }, -); - -test.serial('ignored files - dot files using files attribute', mockPkgDir, ['test', 'fixtures', 'package'], - async (t, {getNewAndUnpublishedFiles}) => { - t.deepEqual(await getNewAndUnpublishedFiles({files: ['source']}, ['test/.dotfile']), []); - }, -); - -test.serial('ignored files - dot files using .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], - async (t, {getNewAndUnpublishedFiles}) => { - t.deepEqual(await getNewAndUnpublishedFiles({}, ['test/.dot']), []); - }, -); - -test.serial('ignored files - ignore strategy is not used', mockPkgDir, ['test', 'fixtures'], - async (t, {getNewAndUnpublishedFiles}) => { - t.deepEqual(await getNewAndUnpublishedFiles({name: 'no ignore strategy'}, newFiles), []); - }, -); - -test.serial('first time published files using file-attribute in package.json with one file', mockPkgDir, ['test', 'fixtures', 'package'], - async (t, {getFirstTimePublishedFiles}) => { - t.deepEqual(await getFirstTimePublishedFiles({files: ['pay_attention.txt']}, newFiles), ['source/pay_attention.txt']); - }, -); - -test.serial('first time published files using file-attribute in package.json with directory', mockPkgDir, ['test', 'fixtures', 'package'], - async (t, {getFirstTimePublishedFiles}) => { - t.deepEqual(await getFirstTimePublishedFiles({files: ['source']}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt']); - }, -); - -test.serial('first time published files using .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], - async (t, {getFirstTimePublishedFiles}) => { - t.deepEqual(await getFirstTimePublishedFiles({name: 'npmignore'}, newFiles), ['source/pay_attention.txt']); - }, -); - -test.serial('first time published dot files using files attribute', mockPkgDir, ['test', 'fixtures', 'package'], - async (t, {getFirstTimePublishedFiles}) => { - t.deepEqual(await getFirstTimePublishedFiles({files: ['source']}, ['source/.dotfile']), ['source/.dotfile']); - }, -); - -test.serial('first time published dot files using .npmignore', mockPkgDir, ['test', 'fixtures', 'npmignore'], - async (t, {getFirstTimePublishedFiles}) => { - t.deepEqual(await getFirstTimePublishedFiles({}, ['source/.dotfile']), ['source/.dotfile']); - }, -); - -test.serial('first time published files - ignore strategy is not used', mockPkgDir, ['test', 'fixtures'], - async (t, {getFirstTimePublishedFiles}) => { - t.deepEqual(await getFirstTimePublishedFiles({name: 'no ignore strategy'}, newFiles), ['source/ignore.txt', 'source/pay_attention.txt', 'test/file.txt']); - }, -); - -test.serial('first time published files - empty files property', mockPkgDir, ['test', 'fixtures', 'package'], - async (t, {getFirstTimePublishedFiles}) => { - t.deepEqual(await getFirstTimePublishedFiles({files: []}, newFiles), []); - }, -); - -test.serial('first time published files - .npmignore excludes everything', mockPkgDir, ['test', 'fixtures', 'npmignore'], - async (t, {getFirstTimePublishedFiles}) => { - t.deepEqual(await getFirstTimePublishedFiles({name: 'excluded everything'}, ['source/ignore.txt']), []); - }, -); From a5d4c3db97304f799a053704dba626857b9f5ca3 Mon Sep 17 00:00:00 2001 From: Tommy Date: Thu, 6 Apr 2023 02:18:41 -0500 Subject: [PATCH 047/180] Fix remote history check - check if `git fetch` needs to be run (#685) --- source/git-util.js | 34 +++++++++--- test/_utils.js | 44 +++++++-------- test/git-tasks.js | 106 +++++++++++++++++++++++++++++-------- test/prerequisite-tasks.js | 28 +++++----- 4 files changed, 146 insertions(+), 66 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index 2f76d561..207a145f 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -133,21 +133,39 @@ export const verifyWorkingTreeIsClean = async () => { } }; -export const isRemoteHistoryClean = async () => { - let history; - try { // Gracefully handle no remote set up. - const {stdout} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); - history = stdout; - } catch {} - - if (history && history !== '0') { +const hasRemote = async () => { + try { + await execa('git', ['rev-parse', '@{u}']); + } catch { // Has no remote if command fails return false; } return true; }; +const hasUnfetchedChangesFromRemote = async () => { + const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run']); + + // There are no unfetched changes if output is empty. + return !possibleNewChanges || possibleNewChanges === ''; +}; + +const isRemoteHistoryClean = async () => { + const {stdout: history} = await execa('git', ['rev-list', '--count', '--left-only', '@{u}...HEAD']); + + // Remote history is clean if there are 0 revisions. + return history === '0'; +}; + export const verifyRemoteHistoryIsClean = async () => { + if (!(await hasRemote())) { + return; + } + + if (!(await hasUnfetchedChangesFromRemote())) { + throw new Error('Remote history differs. Please run `git fetch` and pull changes.'); + } + if (!(await isRemoteHistoryClean())) { throw new Error('Remote history differs. Please pull changes.'); } diff --git a/test/_utils.js b/test/_utils.js index f91da530..c87b122f 100644 --- a/test/_utils.js +++ b/test/_utils.js @@ -1,34 +1,36 @@ import esmock from 'esmock'; +import sinon from 'sinon'; import {execa} from 'execa'; import {SilentRenderer} from './fixtures/listr-renderer.js'; -export const _stubExeca = source => async (t, commands) => esmock(source, {}, { - execa: { - async execa(...args) { - const results = await Promise.all(commands.map(async result => { - const argsMatch = await t.try(tt => { - const [command, ...commandArgs] = result.command.split(' '); - tt.deepEqual(args, [command, commandArgs]); - }); +const makeExecaStub = commands => { + const stub = sinon.stub(); - if (argsMatch.passed) { - argsMatch.discard(); + for (const result of commands) { + const [command, ...commandArgs] = result.command.split(' '); - if (!result.exitCode || result.exitCode === 0) { - return result; - } + // Command passes if the exit code is 0, or if there's no exit code and no stderr. + const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); - throw result; - } + if (passes) { + stub.withArgs(command, commandArgs).resolves(result); + } else { + stub.withArgs(command, commandArgs).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message + } + } + + return stub; +}; - argsMatch.discard(); - })); +export const _stubExeca = source => async commands => { + const execaStub = makeExecaStub(commands); - const result = results.filter(Boolean).at(0); - return result ?? execa(...args); + return esmock(source, {}, { + execa: { + execa: async (...args) => execaStub.resolves(execa(...args))(...args), }, - }, -}); + }); +}; export const run = async listr => { listr.setRenderer(SilentRenderer); diff --git a/test/git-tasks.js b/test/git-tasks.js index d390d0c5..c39963d3 100644 --- a/test/git-tasks.js +++ b/test/git-tasks.js @@ -15,9 +15,8 @@ test.afterEach(() => { }); test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', async t => { - const gitTasks = await stubExeca(t, [{ + const gitTasks = await stubExeca([{ command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'feature', }]); @@ -30,9 +29,8 @@ test.serial('should fail when release branch is not specified, current branch is }); test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', async t => { - const gitTasks = await stubExeca(t, [{ + const gitTasks = await stubExeca([{ command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'feature', }]); @@ -45,21 +43,26 @@ test.serial('should fail when current branch is not the specified release branch }); test.serial('should not fail when current branch not master and publishing from any branch permitted', async t => { - const gitTasks = await stubExeca(t, [ + const gitTasks = await stubExeca([ { command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'feature', }, { command: 'git status --porcelain', - exitCode: 0, stdout: '', }, { - command: 'git rev-list --count --left-only @{u}...HEAD', + command: 'git rev-parse @{u}', exitCode: 0, - stdout: '', + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', }, ]); @@ -71,15 +74,13 @@ test.serial('should not fail when current branch not master and publishing from }); test.serial('should fail when local working tree modified', async t => { - const gitTasks = await stubExeca(t, [ + const gitTasks = await stubExeca([ { command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'master', }, { command: 'git status --porcelain', - exitCode: 0, stdout: 'M source/git-tasks.js', }, ]); @@ -92,22 +93,48 @@ test.serial('should fail when local working tree modified', async t => { assertTaskFailed(t, 'Check local working tree'); }); -test.serial('should fail when remote history differs', async t => { - const gitTasks = await stubExeca(t, [ +test.serial('should not fail when no remote set up', async t => { + const gitTasks = await stubExeca([ { command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'master', }, { command: 'git status --porcelain', - exitCode: 0, stdout: '', }, { - command: 'git rev-list --count --left-only @{u}...HEAD', + command: 'git rev-parse @{u}', + stderr: 'fatal: no upstream configured for branch \'master\'', + }, + ]); + + await t.notThrowsAsync( + run(gitTasks({branch: 'master'})), + ); +}); + +test.serial('should fail when remote history differs and changes are fetched', async t => { + const gitTasks = await stubExeca([ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', exitCode: 0, - stdout: '1', + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '1', // Has unpulled changes }, ]); @@ -119,23 +146,56 @@ test.serial('should fail when remote history differs', async t => { assertTaskFailed(t, 'Check remote history'); }); -test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => { - const gitTasks = await stubExeca(t, [ +test.serial('should fail when remote has unfetched changes', async t => { + const gitTasks = await stubExeca([ { command: 'git symbolic-ref --short HEAD', - exitCode: 0, stdout: 'master', }, { command: 'git status --porcelain', - exitCode: 0, stdout: '', }, { - command: 'git rev-list --count --left-only @{u}...HEAD', + command: 'git rev-parse @{u}', exitCode: 0, + }, + { + command: 'git fetch --dry-run', + stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes + }, + ]); + + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); +}); + +test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => { + const gitTasks = await stubExeca([ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', stdout: '', }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, ]); await t.notThrowsAsync( diff --git a/test/prerequisite-tasks.js b/test/prerequisite-tasks.js index e693e6f8..9be09523 100644 --- a/test/prerequisite-tasks.js +++ b/test/prerequisite-tasks.js @@ -20,7 +20,7 @@ test.afterEach(() => { }); test.serial('public-package published on npm registry: should fail when npm registry not pingable', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'npm ping', exitCode: 1, exitCodeName: 'EPERM', @@ -37,7 +37,7 @@ test.serial('public-package published on npm registry: should fail when npm regi }); test.serial('private package: should disable task pinging npm registry', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', exitCode: 0, stdout: '', @@ -51,7 +51,7 @@ test.serial('private package: should disable task pinging npm registry', async t }); test.serial('external registry: should disable task pinging npm registry', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', exitCode: 0, stdout: '', @@ -65,7 +65,7 @@ test.serial('external registry: should disable task pinging npm registry', async }); test.serial('should fail when npm version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca(t, [ + const prerequisiteTasks = await stubExeca([ { command: 'npm --version', exitCode: 0, @@ -89,7 +89,7 @@ test.serial('should fail when npm version does not match range in `package.json` }); test.serial('should fail when yarn version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca(t, [ + const prerequisiteTasks = await stubExeca([ { command: 'yarn --version', exitCode: 0, @@ -113,7 +113,7 @@ test.serial('should fail when yarn version does not match range in `package.json }); test.serial('should fail when user is not authenticated at npm registry', async t => { - const prerequisiteTasks = await stubExeca(t, [ + const prerequisiteTasks = await stubExeca([ { command: 'npm whoami', exitCode: 0, @@ -139,7 +139,7 @@ test.serial('should fail when user is not authenticated at npm registry', async }); test.serial('should fail when user is not authenticated at external registry', async t => { - const prerequisiteTasks = await stubExeca(t, [ + const prerequisiteTasks = await stubExeca([ { command: 'npm whoami --registry http://my.io', exitCode: 0, @@ -170,7 +170,7 @@ test.serial('should fail when user is not authenticated at external registry', a }); test.serial('private package: should disable task `verify user is authenticated`', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', exitCode: 0, stdout: '', @@ -188,7 +188,7 @@ test.serial('private package: should disable task `verify user is authenticated` }); test.serial('should fail when git version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git version', exitCode: 0, stdout: 'git version 1.0.0', @@ -205,7 +205,7 @@ test.serial('should fail when git version does not match range in `package.json` }); test.serial('should fail when git remote does not exist', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git ls-remote origin HEAD', exitCode: 1, exitCodeName: 'EPERM', @@ -248,7 +248,7 @@ test.serial('should fail when prerelease version of public package without dist }); test.serial('should not fail when prerelease version of public package with dist tag given', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }]); @@ -259,7 +259,7 @@ test.serial('should not fail when prerelease version of public package with dist }); test.serial('should not fail when prerelease version of private package without dist tag given', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }]); @@ -270,7 +270,7 @@ test.serial('should not fail when prerelease version of private package without }); test.serial('should fail when git tag already exists', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: 'vvb', }]); @@ -284,7 +284,7 @@ test.serial('should fail when git tag already exists', async t => { }); test.serial('checks should pass', async t => { - const prerequisiteTasks = await stubExeca(t, [{ + const prerequisiteTasks = await stubExeca([{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }]); From 42a50952704568c4c6fac9299eb1c631de76ce98 Mon Sep 17 00:00:00 2001 From: Tommy Date: Thu, 6 Apr 2023 02:19:21 -0500 Subject: [PATCH 048/180] Use `renameFile` from `move-file` for `new-files` fixtures (#687) --- package.json | 1 + test/new-files.js | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 920aa6ee..3a02660a 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "common-tags": "^1.8.2", "esmock": "^2.2.1", "fs-extra": "^11.1.1", + "move-file": "^3.1.0", "sinon": "^15.0.3", "tempy": "^3.0.0", "write-pkg": "^5.1.0", diff --git a/test/new-files.js b/test/new-files.js index 66da1f0e..9b1046d2 100644 --- a/test/new-files.js +++ b/test/new-files.js @@ -1,7 +1,7 @@ import path from 'node:path'; -import fs from 'node:fs/promises'; import test from 'ava'; import esmock from 'esmock'; +import {renameFile} from 'move-file'; import {runIfExists} from './_utils.js'; const getFixture = name => path.resolve('test', 'fixtures', 'files', name); @@ -59,10 +59,10 @@ test('package.json files field and npmignore', mockPkgDir, 'files-and-npmignore' const renameDotGitignore = { async before(fixtureDir) { - await fs.rename(`${fixtureDir}/gitignore`, `${fixtureDir}/.gitignore`); + await renameFile('gitignore', '.gitignore', {cwd: fixtureDir}); }, async after(fixtureDir) { - await fs.rename(`${fixtureDir}/.gitignore`, `${fixtureDir}/gitignore`); + await renameFile('.gitignore', 'gitignore', {cwd: fixtureDir}); }, }; From eba203f87066e535c5956f07e814cb0e828ce54d Mon Sep 17 00:00:00 2001 From: Tommy Date: Thu, 6 Apr 2023 09:38:10 -0500 Subject: [PATCH 049/180] Improve startup: ensure `package.json` is only parsed once (#688) --- source/cli-implementation.js | 14 +++++------ source/config.js | 5 ++-- source/git-util.js | 16 ++++--------- source/index.js | 7 ++---- source/npm/util.js | 14 ++++------- source/ui.js | 20 ++++++++-------- source/util.js | 19 +++++++-------- test/config.js | 6 ++--- test/index.js | 11 +++++---- test/integration.js | 15 ++++++------ test/{new-files.js => packed-files.js} | 32 +++++++++++--------------- 11 files changed, 70 insertions(+), 89 deletions(-) rename test/{new-files.js => packed-files.js} (50%) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 3b394f92..5ccdf694 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -7,10 +7,10 @@ import updateNotifier from 'update-notifier'; import hasYarn from 'has-yarn'; import {gracefulExit} from 'exit-hook'; import config from './config.js'; +import * as util from './util.js'; import * as git from './git-util.js'; -import {isPackageNameAvailable} from './npm/util.js'; +import * as npm from './npm/util.js'; import Version from './version.js'; -import * as util from './util.js'; import ui from './ui.js'; import np from './index.js'; @@ -99,7 +99,7 @@ const cli = meow(` updateNotifier({pkg: cli.pkg}).notify(); try { - const {pkg, pkgPath} = await util.readPkg(); + const {pkg, rootDir} = await util.readPkg(cli.flags.contents); const defaultFlags = { cleanup: true, @@ -110,7 +110,7 @@ try { '2fa': true, }; - const localConfig = await config(); + const localConfig = await config(rootDir); const flags = { ...defaultFlags, @@ -125,7 +125,7 @@ try { const runPublish = !flags.releaseDraftOnly && flags.publish && !pkg.private; - const availability = flags.publish ? await isPackageNameAvailable(pkg) : { + const availability = flags.publish ? await npm.isPackageNameAvailable(pkg) : { isAvailable: false, isUnknown: false, }; @@ -140,14 +140,14 @@ try { version, runPublish, branch, - }, {pkg, pkgPath}); + }, {pkg, rootDir}); if (!options.confirm) { gracefulExit(); } console.log(); // Prints a newline for readability - const newPkg = await np(options.version, options); + const newPkg = await np(options.version, options, {pkg, rootDir}); if (options.preview || options.releaseDraftOnly) { gracefulExit(); diff --git a/source/config.js b/source/config.js index cd78f7b8..954b66d8 100644 --- a/source/config.js +++ b/source/config.js @@ -1,6 +1,5 @@ import os from 'node:os'; import isInstalledGlobally from 'is-installed-globally'; -import {packageDirectory} from 'pkg-dir'; import {cosmiconfig} from 'cosmiconfig'; // TODO: remove when cosmiconfig/cosmiconfig#283 lands @@ -9,8 +8,8 @@ const loadESM = async filepath => { return module.default ?? module; }; -const getConfig = async () => { - const searchDir = isInstalledGlobally ? os.homedir() : await packageDirectory(); +const getConfig = async rootDir => { + const searchDir = isInstalledGlobally ? os.homedir() : rootDir; const searchPlaces = ['.np-config.json', '.np-config.js', '.np-config.cjs', '.np-config.mjs']; if (!isInstalledGlobally) { searchPlaces.push('package.json'); diff --git a/source/git-util.js b/source/git-util.js index 207a145f..7b114cb8 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -2,7 +2,6 @@ import path from 'node:path'; import {execa} from 'execa'; import escapeStringRegexp from 'escape-string-regexp'; import ignoreWalker from 'ignore-walk'; -import {packageDirectory} from 'pkg-dir'; import Version from './version.js'; export const latestTag = async () => { @@ -15,7 +14,7 @@ export const root = async () => { return stdout; }; -export const newFilesSinceLastRelease = async () => { +export const newFilesSinceLastRelease = async rootDir => { try { const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await latestTag(), 'HEAD']); if (stdout.trim().length === 0) { @@ -27,7 +26,7 @@ export const newFilesSinceLastRelease = async () => { } catch { // Get all files under version control return ignoreWalker({ - path: await packageDirectory(), + path: rootDir, ignoreFiles: ['.gitignore'], }); } @@ -205,12 +204,7 @@ export const tagExistsOnRemote = async tagName => { async function hasLocalBranch(branch) { try { - await execa('git', [ - 'show-ref', - '--verify', - '--quiet', - `refs/heads/${branch}`, - ]); + await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); return true; } catch { return false; @@ -225,9 +219,7 @@ export const defaultBranch = async () => { } } - throw new Error( - 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.', - ); + throw new Error('Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'); }; export const verifyTagDoesNotExistOnRemote = async tagName => { diff --git a/source/index.js b/source/index.js index a3663b01..c50319b5 100644 --- a/source/index.js +++ b/source/index.js @@ -6,7 +6,6 @@ import Listr from 'listr'; import {merge, throwError, catchError, filter, finalize} from 'rxjs'; import {readPackageUp} from 'read-pkg-up'; import hasYarn from 'has-yarn'; -import {packageDirectorySync} from 'pkg-dir'; import hostedGitInfo from 'hosted-git-info'; import onetime from 'onetime'; import {asyncExitHook} from 'exit-hook'; @@ -15,10 +14,10 @@ import prerequisiteTasks from './prerequisite-tasks.js'; import gitTasks from './git-tasks.js'; import publish from './npm/publish.js'; import enable2fa from './npm/enable-2fa.js'; -import * as npm from './npm/util.js'; import releaseTaskHelper from './release-task-helper.js'; import * as util from './util.js'; import * as git from './git-util.js'; +import * as npm from './npm/util.js'; const exec = (cmd, args) => { // Use `Observable` support if merged https://github.com/sindresorhus/execa/pull/26 @@ -28,7 +27,7 @@ const exec = (cmd, args) => { }; // eslint-disable-next-line complexity -const np = async (input = 'patch', options) => { +const np = async (input = 'patch', options, {pkg, rootDir}) => { if (!hasYarn() && options.yarn) { throw new Error('Could not use Yarn without yarn.lock file'); } @@ -38,12 +37,10 @@ const np = async (input = 'patch', options) => { options.cleanup = false; } - const {pkg} = await util.readPkg(options.contents); const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; const pkgManager = options.yarn === true ? 'yarn' : 'npm'; const pkgManagerName = options.yarn === true ? 'Yarn' : 'npm'; - const rootDir = packageDirectorySync(); const hasLockFile = fs.existsSync(path.resolve(rootDir, options.yarn ? 'yarn.lock' : 'package-lock.json')) || fs.existsSync(path.resolve(rootDir, 'npm-shrinkwrap.json')); const isOnGitHub = options.repoUrl && hostedGitInfo.fromUrl(options.repoUrl)?.type === 'github'; const testScript = options.testScript || 'test'; diff --git a/source/npm/util.js b/source/npm/util.js index 7cf46998..fec23fd9 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -5,7 +5,6 @@ import pTimeout from 'p-timeout'; import ow from 'ow'; import npmName from 'npm-name'; import chalk from 'chalk'; -import {packageDirectory} from 'pkg-dir'; import semver from 'semver'; import Version from '../version.js'; @@ -129,21 +128,18 @@ export const verifyRecentNpmVersion = async () => { Version.verifyRequirementSatisfied('npm', npmVersion); }; -const npmignoreExistsInPackageRootDir = async () => { - const rootDir = await packageDirectory(); - return pathExists(path.resolve(rootDir, '.npmignore')); -}; +export const checkIgnoreStrategy = ({files}, rootDir) => { + const npmignoreExistsInPackageRootDir = pathExists(path.resolve(rootDir, '.npmignore')); -export const checkIgnoreStrategy = async ({files}) => { - if (!files && !(await npmignoreExistsInPackageRootDir())) { + if (!files && !npmignoreExistsInPackageRootDir) { console.log(` \n${chalk.bold.yellow('Warning:')} No ${chalk.bold.cyan('files')} field specified in ${chalk.bold.magenta('package.json')} nor is a ${chalk.bold.magenta('.npmignore')} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. `); } }; -export const getFilesToBePacked = async () => { - const {stdout} = await execa('npm', ['pack', '--dry-run', '--json'], {cwd: await packageDirectory()}); +export const getFilesToBePacked = async rootDir => { + const {stdout} = await execa('npm', ['pack', '--dry-run', '--json'], {cwd: rootDir}); const {files} = JSON.parse(stdout).at(0); return files.map(file => file.path); diff --git a/source/ui.js b/source/ui.js index 389cf53e..1e60e51d 100644 --- a/source/ui.js +++ b/source/ui.js @@ -6,7 +6,7 @@ import isScoped from 'is-scoped'; import isInteractive from 'is-interactive'; import * as util from './util.js'; import * as git from './git-util.js'; -import * as npmUtil from './npm/util.js'; +import * as npm from './npm/util.js'; import Version from './version.js'; import prettyVersionDiff from './pretty-version-diff.js'; @@ -78,9 +78,9 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch }; }; -const checkNewFilesAndDependencies = async (pkg, pkgPath) => { - const newFiles = await util.getNewFiles(pkg); - const newDependencies = await util.getNewDependencies(pkg, pkgPath); +const checkNewFilesAndDependencies = async (pkg, rootDir) => { + const newFiles = await util.getNewFiles(rootDir); + const newDependencies = await util.getNewDependencies(pkg, rootDir); const noNewUnpublishedFiles = !newFiles.unpublished || newFiles.unpublished.length === 0; const noNewFirstTimeFiles = !newFiles.firstTime || newFiles.firstTime.length === 0; @@ -121,18 +121,18 @@ const checkNewFilesAndDependencies = async (pkg, pkgPath) => { }; // eslint-disable-next-line complexity -const ui = async (options, {pkg, pkgPath}) => { +const ui = async (options, {pkg, rootDir}) => { const oldVersion = pkg.version; const extraBaseUrls = ['gitlab.com']; const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); const pkgManager = options.yarn ? 'yarn' : 'npm'; - const registryUrl = await npmUtil.getRegistryUrl(pkgManager, pkg); + const registryUrl = await npm.getRegistryUrl(pkgManager, pkg); const releaseBranch = options.branch; if (options.runPublish) { - await npmUtil.checkIgnoreStrategy(pkg); + npm.checkIgnoreStrategy(pkg, rootDir); - const answerIgnoredFiles = await checkNewFilesAndDependencies(pkg, pkgPath); + const answerIgnoredFiles = await checkNewFilesAndDependencies(pkg, rootDir); if (!answerIgnoredFiles) { return { ...options, @@ -253,7 +253,7 @@ const ui = async (options, {pkg, pkgPath}) => { message: 'How should this pre-release version be tagged in npm?', when: answers => options.runPublish && (Version.isPrereleaseOrIncrement(answers.customVersion) || Version.isPrereleaseOrIncrement(answers.version)) && !options.tag, async choices() { - const existingPrereleaseTags = await npmUtil.prereleaseTags(pkg.name); + const existingPrereleaseTags = await npm.prereleaseTags(pkg.name); return [ ...existingPrereleaseTags, @@ -283,7 +283,7 @@ const ui = async (options, {pkg, pkgPath}) => { }, publishScoped: { type: 'confirm', - when: isScoped(pkg.name) && options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !npmUtil.isExternalRegistry(pkg), + when: isScoped(pkg.name) && options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !npm.isExternalRegistry(pkg), message: `This scoped repo ${chalk.bold.magenta(pkg.name)} hasn't been published. Do you want to publish it publicly?`, default: false, }, diff --git a/source/util.js b/source/util.js index 88960b55..dda0f951 100644 --- a/source/util.js +++ b/source/util.js @@ -1,3 +1,4 @@ +import path from 'node:path'; import {readPackageUp} from 'read-pkg-up'; import issueRegex from 'issue-regex'; import terminalLink from 'terminal-link'; @@ -6,8 +7,8 @@ import pMemoize from 'p-memoize'; import ow from 'ow'; import chalk from 'chalk'; import {packageDirectory} from 'pkg-dir'; -import * as gitUtil from './git-util.js'; -import * as npmUtil from './npm/util.js'; +import * as git from './git-util.js'; +import * as npm from './npm/util.js'; export const readPkg = async packagePath => { packagePath = packagePath ? await packageDirectory(packagePath) : await packageDirectory(); @@ -15,11 +16,11 @@ export const readPkg = async packagePath => { throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); } - const {packageJson, path} = await readPackageUp({ + const {packageJson, path: pkgPath} = await readPackageUp({ cwd: packagePath, }); - return {pkg: packageJson, pkgPath: path}; + return {pkg: packageJson, rootDir: path.dirname(pkgPath)}; }; export const linkifyIssues = (url, message) => { @@ -72,9 +73,9 @@ export const getTagVersionPrefix = pMemoize(async options => { export const joinList = list => chalk.reset(list.map(item => `- ${item}`).join('\n')); -export const getNewFiles = async () => { - const listNewFiles = await gitUtil.newFilesSinceLastRelease(); - const listPkgFiles = await npmUtil.getFilesToBePacked(); +export const getNewFiles = async rootDir => { + const listNewFiles = await git.newFilesSinceLastRelease(rootDir); + const listPkgFiles = await npm.getFilesToBePacked(rootDir); return { unpublished: listNewFiles.filter(file => !listPkgFiles.includes(file) && !file.startsWith('.git')), @@ -82,8 +83,8 @@ export const getNewFiles = async () => { }; }; -export const getNewDependencies = async (newPkg, pkgPath) => { - let oldPkg = await gitUtil.readFileFromLastRelease(pkgPath); +export const getNewDependencies = async (newPkg, rootDir) => { + let oldPkg = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); oldPkg = JSON.parse(oldPkg); const newDependencies = []; diff --git a/test/config.js b/test/config.js index 0800c14d..de56e1d9 100644 --- a/test/config.js +++ b/test/config.js @@ -14,10 +14,9 @@ const getConfigsWhenGlobalBinaryIsUsed = async homedirStub => { const promises = pathsPkgDir.map(async pathPkgDir => { const getConfig = await esmock(testedModulePath, { 'is-installed-globally': true, - 'pkg-dir': {packageDirectory: async () => pathPkgDir}, 'node:os': {homedir: homedirStub}, }); - return getConfig(); + return getConfig(pathPkgDir); }); return Promise.all(promises); @@ -29,10 +28,9 @@ const getConfigsWhenLocalBinaryIsUsed = async pathPkgDir => { const promises = homedirs.map(async homedir => { const getConfig = await esmock(testedModulePath, { 'is-installed-globally': false, - 'pkg-dir': {packageDirectory: async () => pathPkgDir}, 'node:os': {homedir: () => homedir}, }); - return getConfig(); + return getConfig(pathPkgDir); }); return Promise.all(promises); diff --git a/test/index.js b/test/index.js index dbf61745..03a0660c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,6 +1,7 @@ import test from 'ava'; import sinon from 'sinon'; import esmock from 'esmock'; +import * as util from '../source/util.js'; import np from '../source/index.js'; const defaultOptions = { @@ -15,9 +16,11 @@ const defaultOptions = { renderer: 'silent', }; +const npPkg = await util.readPkg(); + const npFails = test.macro(async (t, inputs, message) => { await t.throwsAsync( - Promise.all(inputs.map(input => np(input, defaultOptions))), + Promise.all(inputs.map(input => np(input, defaultOptions, npPkg))), {message}, ); }); @@ -51,7 +54,7 @@ test('skip enabling 2FA if the package exists', async t => { }, '../source/npm/enable-2fa.js': enable2faStub, '../source/npm/publish.js': sinon.stub().returns({pipe: sinon.stub()}), - }, {}); + }); await t.notThrowsAsync(npMock('1.0.0', { ...defaultOptions, @@ -59,7 +62,7 @@ test('skip enabling 2FA if the package exists', async t => { isAvailable: false, isUnknown: false, }, - })); + }, npPkg)); t.true(enable2faStub.notCalled); }); @@ -87,7 +90,7 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { isUnknown: false, }, '2fa': false, - })); + }, npPkg)); t.true(enable2faStub.notCalled); }); diff --git a/test/integration.js b/test/integration.js index 25abc62b..fab34722 100644 --- a/test/integration.js +++ b/test/integration.js @@ -41,13 +41,12 @@ test('main', async t => { }); }); -const createFixture = test.macro(async (t, pkgFiles, commands, {unpublished, firstTime}) => { +const createNewFilesFixture = test.macro(async (t, pkgFiles, commands, {unpublished, firstTime}) => { await createIntegrationTest(t, async ($$, temporaryDir) => { /** @type {import('../source/util.js')} */ const util = await esmock('../source/util.js', {}, { 'node:process': {cwd: () => temporaryDir}, execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, - 'pkg-dir': {packageDirectory: async () => temporaryDir}, }); await commands(t, $$, temporaryDir); @@ -59,13 +58,13 @@ const createFixture = test.macro(async (t, pkgFiles, commands, {unpublished, fir }); t.deepEqual( - await util.getNewFiles(), + await util.getNewFiles(temporaryDir), {unpublished, firstTime}, ); }); }); -test('files to package with tags added', createFixture, ['*.js'], async (t, $$) => { +test('files to package with tags added', createNewFilesFixture, ['*.js'], async (t, $$) => { await $$`git tag v0.0.0`; await t.context.createFile('new'); await t.context.createFile('index.js'); @@ -73,7 +72,7 @@ test('files to package with tags added', createFixture, ['*.js'], async (t, $$) await $$`git commit -m "added"`; }, {unpublished: ['new'], firstTime: ['index.js']}); -test('file `new` to package without tags added', createFixture, ['index.js'], async t => { +test('file `new` to package without tags added', createNewFilesFixture, ['index.js'], async t => { await t.context.createFile('new'); await t.context.createFile('index.js'); }, {unpublished: ['new'], firstTime: ['index.js', 'package.json']}); @@ -83,7 +82,7 @@ test('file `new` to package without tags added', createFixture, ['index.js'], as const filePath1 = path.join(longPath, 'file1'); const filePath2 = path.join(longPath, 'file2'); - test('files with long pathnames added', createFixture, ['*.js'], async (t, $$) => { + test('files with long pathnames added', createNewFilesFixture, ['*.js'], async (t, $$) => { await $$`git tag v0.0.0`; await t.context.createFile(filePath1); await t.context.createFile(filePath2); @@ -92,11 +91,11 @@ test('file `new` to package without tags added', createFixture, ['index.js'], as }, {unpublished: [filePath1, filePath2], firstTime: []}); })(); -test('no new files added', createFixture, [], async (_t, $$) => { +test('no new files added', createNewFilesFixture, [], async (_t, $$) => { await $$`git tag v0.0.0`; }, {unpublished: [], firstTime: []}); -test('ignores .git and .github files', createFixture, ['*.js'], async (t, $$) => { +test('ignores .git and .github files', createNewFilesFixture, ['*.js'], async (t, $$) => { await $$`git tag v0.0.0`; await t.context.createFile('.github/workflows/main.yml'); await t.context.createFile('.github/pull_request_template'); diff --git a/test/new-files.js b/test/packed-files.js similarity index 50% rename from test/new-files.js rename to test/packed-files.js index 9b1046d2..b2a44aad 100644 --- a/test/new-files.js +++ b/test/packed-files.js @@ -1,56 +1,52 @@ import path from 'node:path'; import test from 'ava'; -import esmock from 'esmock'; import {renameFile} from 'move-file'; +import {getFilesToBePacked} from '../source/npm/util.js'; import {runIfExists} from './_utils.js'; const getFixture = name => path.resolve('test', 'fixtures', 'files', name); -const mockPkgDir = test.macro(async (t, fixture, expectedFiles, {before, after} = {}) => { +const verifyPackedFiles = test.macro(async (t, fixture, expectedFiles, {before, after} = {}) => { const fixtureDir = getFixture(fixture); await runIfExists(before, fixtureDir); t.teardown(async () => runIfExists(after, fixtureDir)); - const npmUtil = await esmock('../source/npm/util.js', { - 'pkg-dir': {packageDirectory: async () => fixtureDir}, - }); - - const files = await npmUtil.getFilesToBePacked(); + const files = await getFilesToBePacked(fixtureDir); t.deepEqual(files.sort(), [...expectedFiles, 'package.json'].sort(), 'Files different from expectations!'); }); -test('package.json files field - one file', mockPkgDir, 'one-file', [ +test('package.json files field - one file', verifyPackedFiles, 'one-file', [ 'index.js', ]); -test('package.json files field - source dir', mockPkgDir, 'source-dir', [ +test('package.json files field - source dir', verifyPackedFiles, 'source-dir', [ 'source/foo.js', 'source/bar.js', ]); -test('package.json files field - source and dist dirs', mockPkgDir, 'source-and-dist-dir', [ +test('package.json files field - source and dist dirs', verifyPackedFiles, 'source-and-dist-dir', [ 'source/foo.js', 'source/bar.js', ]); -test('package.json files field - leading slash', mockPkgDir, 'files-slash', [ +test('package.json files field - leading slash', verifyPackedFiles, 'files-slash', [ 'index.js', ]); -test('package.json files field - has readme and license', mockPkgDir, 'has-readme-and-license', [ +test('package.json files field - has readme and license', verifyPackedFiles, 'has-readme-and-license', [ 'readme.md', 'license.md', 'index.js', ]); -test('npmignore', mockPkgDir, 'npmignore', [ +test('npmignore', verifyPackedFiles, 'npmignore', [ 'readme.md', 'index.js', 'index.d.ts', ]); -test('package.json files field and npmignore', mockPkgDir, 'files-and-npmignore', [ +test('package.json files field and npmignore', verifyPackedFiles, 'files-and-npmignore', [ 'readme.md', 'source/foo.js', 'source/bar.js', @@ -66,22 +62,22 @@ const renameDotGitignore = { }, }; -test('package.json files field and gitignore', mockPkgDir, 'gitignore', [ +test('package.json files field and gitignore', verifyPackedFiles, 'gitignore', [ 'readme.md', 'dist/index.js', ], renameDotGitignore); -test('npmignore and gitignore', mockPkgDir, 'npmignore-and-gitignore', [ +test('npmignore and gitignore', verifyPackedFiles, 'npmignore-and-gitignore', [ 'readme.md', 'dist/index.js', ], renameDotGitignore); -test('package.json main field not in files field', mockPkgDir, 'main', [ +test('package.json main field not in files field', verifyPackedFiles, 'main', [ 'foo.js', 'bar.js', ]); -test('doesn\'t show files in .github', mockPkgDir, 'dot-github', [ +test('doesn\'t show files in .github', verifyPackedFiles, 'dot-github', [ 'index.js', ]); From 9cb4bfd2976bb53453369fca6cb3f58e6e232c23 Mon Sep 17 00:00:00 2001 From: Timon Jurschitsch <103483059+DerTimonius@users.noreply.github.com> Date: Fri, 14 Apr 2023 19:27:47 +0200 Subject: [PATCH 050/180] Add 2FA support for npm version 9+ (#693) --- source/npm/enable-2fa.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index 03c29ff5..429746f1 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -1,9 +1,12 @@ import {execa} from 'execa'; import {from, catchError} from 'rxjs'; +import semver from 'semver'; import handleNpmError from './handle-npm-error.js'; +import {version as npmVersionCheck} from './util.js'; -export const getEnable2faArgs = (packageName, options) => { - const args = ['access', '2fa-required', packageName]; +export const getEnable2faArgs = async (packageName, options) => { + const npmVersion = await npmVersionCheck(); + const args = semver.satisfies(npmVersion, '>=9.0.0') ? ['access', 'set', 'mfa=publish', packageName] : ['access', '2fa-required', packageName]; if (options && options.otp) { args.push('--otp', options.otp); From b2857a4e037f49300b63b0219b5a8255dd793db2 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 27 May 2023 20:02:15 +0700 Subject: [PATCH 051/180] Update dependencies --- package.json | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 3a02660a..62cf2adb 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,9 @@ "github-url-from-git": "^1.5.0", "has-yarn": "^3.0.0", "hosted-git-info": "^6.1.1", - "ignore-walk": "^6.0.2", + "ignore-walk": "^6.0.3", "import-local": "^3.1.0", - "inquirer": "^9.1.5", + "inquirer": "^9.2.6", "is-installed-globally": "^0.4.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", @@ -51,8 +51,7 @@ "listr": "^0.14.3", "listr-input": "^0.2.1", "log-symbols": "^5.1.0", - "meow": "^11.0.0", - "minimatch": "^8.0.2", + "meow": "^12.0.1", "new-github-release-url": "^2.0.0", "npm-name": "^7.1.0", "onetime": "^6.0.0", @@ -63,22 +62,22 @@ "path-exists": "^5.0.0", "pkg-dir": "^7.0.0", "read-pkg-up": "^9.1.0", - "rxjs": "^7.8.0", - "semver": "^7.3.8", + "rxjs": "^7.8.1", + "semver": "^7.5.1", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", "update-notifier": "^6.0.2" }, "devDependencies": { - "ava": "^5.2.0", + "ava": "^5.3.0", "common-tags": "^1.8.2", - "esmock": "^2.2.1", + "esmock": "^2.2.3", "fs-extra": "^11.1.1", "move-file": "^3.1.0", - "sinon": "^15.0.3", + "sinon": "^15.1.0", "tempy": "^3.0.0", "write-pkg": "^5.1.0", - "xo": "^0.53.1" + "xo": "^0.54.2" }, "ava": { "environmentVariables": { From c1135391b64c64d91056bb947fb4eeca7d4f68e9 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 27 May 2023 20:06:48 +0700 Subject: [PATCH 052/180] 8.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 62cf2adb..ef2e79c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "7.7.0", + "version": "8.0.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From beb7db1ba7d2ec4985d3d0211a7d4a37c72fc80f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 28 May 2023 16:21:37 +0700 Subject: [PATCH 053/180] Fix a crash in the new dependency check Fixes #697 Fixes #696 --- source/util.js | 4 ++-- source/version.js | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/source/util.js b/source/util.js index dda0f951..00c21244 100644 --- a/source/util.js +++ b/source/util.js @@ -89,8 +89,8 @@ export const getNewDependencies = async (newPkg, rootDir) => { const newDependencies = []; - for (const dependency of Object.keys(newPkg.dependencies)) { - if (!oldPkg.dependencies[dependency]) { + for (const dependency of Object.keys(newPkg.dependencies ?? {})) { + if (!oldPkg.dependencies?.[dependency]) { newDependencies.push(dependency); } } diff --git a/source/version.js b/source/version.js index 56710fac..c395f268 100644 --- a/source/version.js +++ b/source/version.js @@ -58,9 +58,17 @@ export default class Version { } static verifyRequirementSatisfied(dependency, version) { - const depRange = pkg.engines[dependency]; - if (!new Version(version).satisfies(depRange)) { - throw new Error(`Please upgrade to ${dependency}${depRange}`); + if (!pkg.engines?.node) { + throw new Error('Please include a `engines.node` field in your package.json'); + } + + const versionRange = pkg.engines?.[dependency]; + if (!versionRange) { + return; + } + + if (!new Version(version).satisfies(versionRange)) { + throw new Error(`Please upgrade to ${dependency}${versionRange}`); } } From d55316b6a992d5382e9ab22618255b75ce4c2313 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 28 May 2023 16:45:34 +0700 Subject: [PATCH 054/180] 8.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef2e79c8..24cd0dd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "8.0.0", + "version": "8.0.1", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 12fce8869cc87ee859305cffd1a04aca7017a8b4 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 30 May 2023 16:08:42 +0700 Subject: [PATCH 055/180] Include stack trace in errors --- source/cli-implementation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 5ccdf694..47748504 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -155,6 +155,6 @@ try { console.log(`\n ${newPkg.name} ${newPkg.version} published πŸŽ‰`); } catch (error) { - console.error(`\n${logSymbols.error} ${error.message}`); + console.error(`\n${logSymbols.error} ${error?.stack ?? error}`); gracefulExit(1); } From 3d448c27ed0e932c983802773fc6f18709ee4167 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 30 May 2023 16:08:10 +0700 Subject: [PATCH 056/180] Fix publish not working with Yarn Fixes #698 --- source/npm/publish.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/npm/publish.js b/source/npm/publish.js index 85a73ddb..76d09290 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -26,7 +26,7 @@ export const getPackagePublishArguments = options => { const pkgPublish = (pkgManager, options) => execa(pkgManager, getPackagePublishArguments(options)); -const publish = (context, pkgManager, task, options) => { +const publish = (context, pkgManager, task, options) => from(pkgPublish(pkgManager, options)).pipe( catchError(error => handleNpmError(error, task, otp => { context.otp = otp; @@ -34,6 +34,5 @@ const publish = (context, pkgManager, task, options) => { return pkgPublish(pkgManager, {...options, otp}); })), ); -}; export default publish; From 9f9ee6ef9ff00375203fe8575a5844e0a0144386 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 30 May 2023 16:11:46 +0700 Subject: [PATCH 057/180] 8.0.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 24cd0dd9..4a70a462 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "8.0.1", + "version": "8.0.2", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 51dcc2d84bbc6a861cfcd5ea9f739d21551bde23 Mon Sep 17 00:00:00 2001 From: Tommy Date: Sun, 11 Jun 2023 03:30:59 -0500 Subject: [PATCH 058/180] Fix skipping publish step (#706) --- source/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/index.js b/source/index.js index c50319b5..28a263cb 100644 --- a/source/index.js +++ b/source/index.js @@ -12,7 +12,7 @@ import {asyncExitHook} from 'exit-hook'; import logSymbols from 'log-symbols'; import prerequisiteTasks from './prerequisite-tasks.js'; import gitTasks from './git-tasks.js'; -import publish from './npm/publish.js'; +import publish, {getPackagePublishArguments} from './npm/publish.js'; import enable2fa from './npm/enable-2fa.js'; import releaseTaskHelper from './release-task-helper.js'; import * as util from './util.js'; @@ -219,7 +219,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { title: `Publishing package using ${pkgManagerName}`, skip() { if (options.preview) { - const args = publish.getPackagePublishArguments(options); + const args = getPackagePublishArguments(options); return `[Preview] Command not executed: ${pkgManager} ${args.join(' ')}.`; } }, From 065193375f17f40f412e0e5cedda098396c8391d Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 11 Jun 2023 11:32:51 +0300 Subject: [PATCH 059/180] 8.0.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a70a462..395d377e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "8.0.2", + "version": "8.0.3", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 3f43d785be2769cf39a586fb3de5677ede6a8be1 Mon Sep 17 00:00:00 2001 From: Tommy Date: Mon, 12 Jun 2023 12:37:41 -0500 Subject: [PATCH 060/180] Handle first time display of dependencies (#707) --- source/util.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/source/util.js b/source/util.js index 00c21244..348bb13d 100644 --- a/source/util.js +++ b/source/util.js @@ -84,7 +84,15 @@ export const getNewFiles = async rootDir => { }; export const getNewDependencies = async (newPkg, rootDir) => { - let oldPkg = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); + let oldPkg; + + try { + oldPkg = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); + } catch { + // Handle first time publish + return Object.keys(newPkg.dependencies ?? {}); + } + oldPkg = JSON.parse(oldPkg); const newDependencies = []; From 0a7abf4b2d170c8215d3767b8dc12806d6e71b66 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 12 Jun 2023 20:40:48 +0300 Subject: [PATCH 061/180] 8.0.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 395d377e..a3492406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "8.0.3", + "version": "8.0.4", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From ad7b09e55a7a6d8ddd47c5afaf5e3615a6800528 Mon Sep 17 00:00:00 2001 From: Werner Robitza Date: Tue, 20 Jun 2023 22:49:22 +0200 Subject: [PATCH 062/180] improve messages related to new files and dependencies (#702) Co-authored-by: Sindre Sorhus --- source/ui.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/ui.js b/source/ui.js index 1e60e51d..6489ca31 100644 --- a/source/ui.js +++ b/source/ui.js @@ -94,15 +94,15 @@ const checkNewFilesAndDependencies = async (pkg, rootDir) => { const messages = []; if (newFiles.unpublished.length > 0) { - messages.push(`The following new files will not be part of your published package:\n${util.joinList(newFiles.unpublished)}`); + messages.push(`The following new files will not be part of your published package:\n${util.joinList(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)}`); + 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)}`); + 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()) { From 885cc945e3b0202c0befe759f193b936b1df3461 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 2 Aug 2023 15:19:01 +0200 Subject: [PATCH 063/180] =?UTF-8?q?@tommy-mitchell=20is=20now=20a=20mainta?= =?UTF-8?q?iner=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- readme.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/readme.md b/readme.md index f04dcf7a..7709706e 100644 --- a/readme.md +++ b/readme.md @@ -351,5 +351,4 @@ npm ERR! 403 Forbidden - GET https://registry.yarnpkg.com/-/package/my-awesome-p ## Maintainers - [Sindre Sorhus](https://github.com/sindresorhus) -- [Sam Verschueren](https://github.com/SamVerschueren) -- [Itai Steinherz](https://github.com/itaisteinherz) +- [Tommy Mitchell](https://github.com/tommy-mitchell) From 1cb22e060af7b253aee657709420f030046fe7f2 Mon Sep 17 00:00:00 2001 From: Tommy Date: Sat, 2 Sep 2023 03:16:35 -0500 Subject: [PATCH 064/180] Internal improvements, add more tests (#689) --- package.json | 46 +-- readme.md | 8 +- source/cli-implementation.js | 33 +-- source/cli.js | 4 +- source/config.js | 2 +- source/git-util.js | 74 ++--- source/index.js | 114 +++---- source/npm/enable-2fa.js | 6 +- source/npm/handle-npm-error.js | 2 +- source/npm/util.js | 39 +-- source/prerequisite-tasks.js | 14 +- source/pretty-version-diff.js | 26 -- source/release-task-helper.js | 13 +- source/ui.js | 71 +++-- source/util.js | 61 ++-- source/version.js | 206 +++++++++---- test/_helpers/integration-test.d.ts | 32 ++ test/_helpers/integration-test.js | 66 +++++ test/{fixtures => _helpers}/listr-renderer.js | 0 test/_helpers/listr.js | 20 ++ test/_helpers/mock-inquirer.js | 223 ++++++++++++++ test/_helpers/stub-execa.d.ts | 14 + test/_helpers/stub-execa.js | 50 ++++ test/_helpers/util.js | 12 + test/_helpers/verify-cli.d.ts | 10 + test/_helpers/verify-cli.js | 16 + test/_utils.js | 58 ---- test/cli.js | 44 +++ test/config.js | 8 +- .../source/index.test-d.ts | 2 +- test/fixtures/files/gitignore/.gitignore | 1 + test/fixtures/files/gitignore/gitignore | 3 - test/fixtures/files/gitignore/index.test-d.ts | 2 +- .../files/npmignore-and-gitignore/.gitignore | 1 + .../files/npmignore-and-gitignore/gitignore | 3 - .../npmignore-and-gitignore/script/build.js | 1 - test/fixtures/files/npmignore/index.test-d.ts | 2 +- test/git-tasks.js | 204 ------------- test/git-util/check-if-file-git-ignored.js | 20 ++ test/git-util/commit-log-from-revision.js | 32 ++ test/git-util/default-branch.js | 37 +++ test/git-util/delete-tag.js | 40 +++ test/git-util/get-current-branch.js | 12 + test/git-util/has-upstream.js | 11 + test/git-util/is-head-detached.js | 18 ++ test/git-util/latest-tag-or-first-commit.js | 33 +++ test/git-util/latest-tag.js | 23 ++ test/git-util/new-files-since-last-release.js | 39 +++ test/git-util/previous-tag-or-first-commit.js | 45 +++ test/git-util/push-graceful.js | 40 +++ test/git-util/read-file-from-last-release.js | 43 +++ test/git-util/remove-last-commit.js | 19 ++ test/git-util/root.js | 17 ++ ...verify-current-branch-is-release-branch.js | 22 ++ test/git-util/verify-recent-git-version.js | 24 ++ .../verify-remote-history-is-clean.js | 74 +++++ test/git-util/verify-remote-is-valid.js | 27 ++ .../verify-tag-does-not-exist-on-remote.js | 26 ++ test/git-util/verify-working-tree-is-clean.js | 24 ++ test/hyperlinks.js | 56 ---- test/index.js | 14 +- test/integration.js | 105 ------- test/npm/enable-2fa.js | 45 +++ test/npm/handle-npm-error.js | 20 ++ test/npm/publish.js | 37 +++ test/npm/util/check-connection.js | 36 +++ test/npm/util/check-ignore-strategy.js | 35 +++ test/npm/util/collaborators.js | 93 ++++++ test/npm/util/get-registry-url.js | 39 +++ test/npm/util/is-external-registry.js | 10 + test/npm/util/is-package-name-available.js | 42 +++ test/{ => npm/util}/packed-files.js | 18 +- test/npm/util/prerelease-tags.js | 89 ++++++ test/npm/util/username.js | 39 +++ test/npm/util/verify-recent-npm-version.js | 24 ++ test/prefix.js | 25 -- test/preid.js | 16 - test/release-task-helper.js | 61 ++++ test/tasks/git-tasks.js | 184 ++++++++++++ test/{ => tasks}/prerequisite-tasks.js | 259 +++++++--------- test/ui/new-files-dependencies.d.ts | 34 +++ test/ui/new-files-dependencies.js | 127 ++++++++ test/ui/prompts/tags.js | 123 ++++++++ test/ui/prompts/version.js | 128 ++++++++ test/util/get-new-dependencies.js | 44 +++ test/util/get-new-files.js | 109 +++++++ test/util/get-pre-release-prefix.js | 72 +++++ test/util/get-tag-version-prefix.js | 52 ++++ test/util/hyperlinks.js | 76 +++++ test/util/join-list.js | 23 ++ test/util/read-pkg.js | 49 ++++ .../util/validate-engine-version-satisfies.js | 24 ++ test/version.js | 277 ++++++++++++------ 93 files changed, 3446 insertions(+), 1056 deletions(-) delete mode 100644 source/pretty-version-diff.js create mode 100644 test/_helpers/integration-test.d.ts create mode 100644 test/_helpers/integration-test.js rename test/{fixtures => _helpers}/listr-renderer.js (100%) create mode 100644 test/_helpers/listr.js create mode 100644 test/_helpers/mock-inquirer.js create mode 100644 test/_helpers/stub-execa.d.ts create mode 100644 test/_helpers/stub-execa.js create mode 100644 test/_helpers/util.js create mode 100644 test/_helpers/verify-cli.d.ts create mode 100644 test/_helpers/verify-cli.js delete mode 100644 test/_utils.js create mode 100644 test/cli.js create mode 100644 test/fixtures/files/gitignore/.gitignore delete mode 100644 test/fixtures/files/gitignore/gitignore create mode 100644 test/fixtures/files/npmignore-and-gitignore/.gitignore delete mode 100644 test/fixtures/files/npmignore-and-gitignore/gitignore delete mode 100644 test/git-tasks.js create mode 100644 test/git-util/check-if-file-git-ignored.js create mode 100644 test/git-util/commit-log-from-revision.js create mode 100644 test/git-util/default-branch.js create mode 100644 test/git-util/delete-tag.js create mode 100644 test/git-util/get-current-branch.js create mode 100644 test/git-util/has-upstream.js create mode 100644 test/git-util/is-head-detached.js create mode 100644 test/git-util/latest-tag-or-first-commit.js create mode 100644 test/git-util/latest-tag.js create mode 100644 test/git-util/new-files-since-last-release.js create mode 100644 test/git-util/previous-tag-or-first-commit.js create mode 100644 test/git-util/push-graceful.js create mode 100644 test/git-util/read-file-from-last-release.js create mode 100644 test/git-util/remove-last-commit.js create mode 100644 test/git-util/root.js create mode 100644 test/git-util/verify-current-branch-is-release-branch.js create mode 100644 test/git-util/verify-recent-git-version.js create mode 100644 test/git-util/verify-remote-history-is-clean.js create mode 100644 test/git-util/verify-remote-is-valid.js create mode 100644 test/git-util/verify-tag-does-not-exist-on-remote.js create mode 100644 test/git-util/verify-working-tree-is-clean.js delete mode 100644 test/hyperlinks.js delete mode 100644 test/integration.js create mode 100644 test/npm/enable-2fa.js create mode 100644 test/npm/handle-npm-error.js create mode 100644 test/npm/publish.js create mode 100644 test/npm/util/check-connection.js create mode 100644 test/npm/util/check-ignore-strategy.js create mode 100644 test/npm/util/collaborators.js create mode 100644 test/npm/util/get-registry-url.js create mode 100644 test/npm/util/is-external-registry.js create mode 100644 test/npm/util/is-package-name-available.js rename test/{ => npm/util}/packed-files.js (81%) create mode 100644 test/npm/util/prerelease-tags.js create mode 100644 test/npm/util/username.js create mode 100644 test/npm/util/verify-recent-npm-version.js delete mode 100644 test/prefix.js delete mode 100644 test/preid.js create mode 100644 test/release-task-helper.js create mode 100644 test/tasks/git-tasks.js rename test/{ => tasks}/prerequisite-tasks.js (53%) create mode 100644 test/ui/new-files-dependencies.d.ts create mode 100644 test/ui/new-files-dependencies.js create mode 100644 test/ui/prompts/tags.js create mode 100644 test/ui/prompts/version.js create mode 100644 test/util/get-new-dependencies.js create mode 100644 test/util/get-new-files.js create mode 100644 test/util/get-pre-release-prefix.js create mode 100644 test/util/get-tag-version-prefix.js create mode 100644 test/util/hyperlinks.js create mode 100644 test/util/join-list.js create mode 100644 test/util/read-pkg.js create mode 100644 test/util/validate-engine-version-satisfies.js diff --git a/package.json b/package.json index a3492406..9de8bdfb 100644 --- a/package.json +++ b/package.json @@ -31,19 +31,20 @@ "commit" ], "dependencies": { - "chalk": "^5.2.0", + "chalk": "^5.3.0", + "chalk-template": "^1.1.0", "cosmiconfig": "^8.1.3", - "del": "^7.0.0", + "del": "^7.1.0", "escape-goat": "^4.0.0", "escape-string-regexp": "^5.0.0", - "execa": "^7.1.1", - "exit-hook": "^3.2.0", + "execa": "^8.0.1", + "exit-hook": "^4.0.0", "github-url-from-git": "^1.5.0", "has-yarn": "^3.0.0", - "hosted-git-info": "^6.1.1", + "hosted-git-info": "^7.0.0", "ignore-walk": "^6.0.3", "import-local": "^3.1.0", - "inquirer": "^9.2.6", + "inquirer": "^9.2.10", "is-installed-globally": "^0.4.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", @@ -51,40 +52,49 @@ "listr": "^0.14.3", "listr-input": "^0.2.1", "log-symbols": "^5.1.0", - "meow": "^12.0.1", + "meow": "^12.1.1", "new-github-release-url": "^2.0.0", "npm-name": "^7.1.0", "onetime": "^6.0.0", "open": "^9.1.0", "ow": "^1.1.1", "p-memoize": "^7.1.1", - "p-timeout": "^6.1.1", + "p-timeout": "^6.1.2", "path-exists": "^5.0.0", "pkg-dir": "^7.0.0", - "read-pkg-up": "^9.1.0", + "read-pkg": "^8.1.0", + "read-pkg-up": "^10.1.0", "rxjs": "^7.8.1", - "semver": "^7.5.1", + "semver": "^7.5.4", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", "update-notifier": "^6.0.2" }, "devDependencies": { - "ava": "^5.3.0", + "@sindresorhus/is": "^6.0.0", + "@types/semver": "^7.5.1", + "ava": "^5.3.1", "common-tags": "^1.8.2", - "esmock": "^2.2.3", + "esmock": "^2.3.8", "fs-extra": "^11.1.1", - "move-file": "^3.1.0", - "sinon": "^15.1.0", - "tempy": "^3.0.0", - "write-pkg": "^5.1.0", - "xo": "^0.54.2" + "map-obj": "^5.0.2", + "sinon": "^15.2.0", + "strip-ansi": "^7.1.0", + "tempy": "^3.1.0", + "write-pkg": "^6.0.0", + "xo": "^0.56.0" }, "ava": { + "files": [ + "!test/fixtures", + "!test/_helpers" + ], "environmentVariables": { "FORCE_HYPERLINK": "1" }, "nodeArguments": [ - "--loader=esmock" + "--loader=esmock", + "--no-warnings=ExperimentalWarning" ] } } diff --git a/readme.md b/readme.md index 7709706e..d31d7b25 100644 --- a/readme.md +++ b/readme.md @@ -73,7 +73,7 @@ $ np --help $ np Version can be: - patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3 + major | minor | patch | premajor | preminor | prepatch | prerelease | 1.2.3 Options --any-branch Allow publishing from any branch @@ -87,7 +87,7 @@ $ np --help --no-yarn Don't use Yarn --contents Subdirectory to publish --no-release-draft Skips opening a GitHub release draft - --release-draft-only Only opens a GitHub release draft + --release-draft-only Only opens a GitHub release draft for the latest published version --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message. `%s` will be replaced with version. (default: '%s' with npm and 'v%s' with yarn) @@ -113,7 +113,7 @@ Run `np` without arguments to launch the interactive UI that guides you through Currently, these are the flags you can configure: - `anyBranch` - Allow publishing from any branch (`false` by default). -- `branch` - Name of the release branch (`master` by default). +- `branch` - Name of the release branch (`main` or `master` by default). - `cleanup` - Cleanup `node_modules` (`true` by default). - `tests` - Run `npm test` (`true` by default). - `yolo` - Skip cleanup and testing (`false` by default). @@ -346,6 +346,8 @@ npm ERR! 403 Forbidden - GET https://registry.yarnpkg.com/-/package/my-awesome-p "publishConfig": { "registry": "https://registry.npmjs.org" } + +Note: On `npm` v9+, the command has been changed to `npm access list collaborators my-awesome-package`. ``` ## Maintainers diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 47748504..e706f2ad 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -10,7 +10,7 @@ import config from './config.js'; import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -import Version from './version.js'; +import {SEMVER_INCREMENTS} from './version.js'; import ui from './ui.js'; import np from './index.js'; @@ -19,7 +19,7 @@ const cli = meow(` $ np Version can be: - ${Version.SEMVER_INCREMENTS.join(' | ')} | 1.2.3 + ${SEMVER_INCREMENTS.join(' | ')} | 1.2.3 Options --any-branch Allow publishing from any branch @@ -56,18 +56,22 @@ const cli = meow(` }, cleanup: { type: 'boolean', + default: true, }, tests: { type: 'boolean', + default: true, }, yolo: { type: 'boolean', }, publish: { type: 'boolean', + default: true, }, releaseDraft: { type: 'boolean', + default: true, }, releaseDraftOnly: { type: 'boolean', @@ -77,6 +81,7 @@ const cli = meow(` }, yarn: { type: 'boolean', + default: hasYarn(), }, contents: { type: 'string', @@ -89,6 +94,7 @@ const cli = meow(` }, '2fa': { type: 'boolean', + default: true, }, message: { type: 'string', @@ -101,19 +107,8 @@ updateNotifier({pkg: cli.pkg}).notify(); try { const {pkg, rootDir} = await util.readPkg(cli.flags.contents); - const defaultFlags = { - cleanup: true, - tests: true, - publish: true, - releaseDraft: true, - yarn: hasYarn(), - '2fa': true, - }; - const localConfig = await config(rootDir); - const flags = { - ...defaultFlags, ...localConfig, ...cli.flags, }; @@ -125,20 +120,22 @@ try { const runPublish = !flags.releaseDraftOnly && flags.publish && !pkg.private; - const availability = flags.publish ? await npm.isPackageNameAvailable(pkg) : { + // TODO: does this need to run if `runPublish` is false? + const availability = runPublish ? await npm.isPackageNameAvailable(pkg) : { isAvailable: false, isUnknown: false, }; - // Use current (latest) version when 'releaseDraftOnly', otherwise use the first argument. - const version = flags.releaseDraftOnly ? pkg.version : (cli.input.length > 0 ? cli.input[0] : false); + // Use current (latest) version when 'releaseDraftOnly', otherwise try to use the first argument. + const version = flags.releaseDraftOnly ? pkg.version : cli.input.at(0); + + const branch = flags.branch ?? await git.defaultBranch(); - const branch = flags.branch || await git.defaultBranch(); const options = await ui({ ...flags, + runPublish, availability, version, - runPublish, branch, }, {pkg, rootDir}); diff --git a/source/cli.js b/source/cli.js index 8c71be61..259300ce 100755 --- a/source/cli.js +++ b/source/cli.js @@ -1,14 +1,12 @@ #!/usr/bin/env node -import {fileURLToPath} from 'node:url'; import {debuglog} from 'node:util'; import importLocal from 'import-local'; import isInstalledGlobally from 'is-installed-globally'; -const __filename = fileURLToPath(import.meta.url); const log = debuglog('np'); // Prefer the local installation -if (!importLocal(__filename)) { +if (!importLocal(import.meta.url)) { if (isInstalledGlobally) { log('Using global install of np.'); } diff --git a/source/config.js b/source/config.js index 954b66d8..6aa41a57 100644 --- a/source/config.js +++ b/source/config.js @@ -2,7 +2,7 @@ import os from 'node:os'; import isInstalledGlobally from 'is-installed-globally'; import {cosmiconfig} from 'cosmiconfig'; -// TODO: remove when cosmiconfig/cosmiconfig#283 lands +// TODO: Remove when cosmiconfig/cosmiconfig#283 lands const loadESM = async filepath => { const module = await import(filepath); return module.default ?? module; diff --git a/source/git-util.js b/source/git-util.js index 7b114cb8..cfe36356 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -2,7 +2,7 @@ import path from 'node:path'; import {execa} from 'execa'; import escapeStringRegexp from 'escape-string-regexp'; import ignoreWalker from 'ignore-walk'; -import Version from './version.js'; +import * as util from './util.js'; export const latestTag = async () => { const {stdout} = await execa('git', ['describe', '--abbrev=0', '--tags']); @@ -33,11 +33,18 @@ export const newFilesSinceLastRelease = async rootDir => { }; export const readFileFromLastRelease = async file => { - const filePathFromRoot = path.relative(await root(), file); + const rootPath = await root(); + const filePathFromRoot = path.relative(rootPath, path.resolve(rootPath, file)); const {stdout: oldFile} = await execa('git', ['show', `${await latestTag()}:${filePathFromRoot}`]); return oldFile; }; +/** Returns an array of tags, sorted by creation date in ascending order. */ +const tagList = async () => { + const {stdout} = await execa('git', ['tag', '--sort=creatordate']); + return stdout ? stdout.split('\n') : []; +}; + const firstCommit = async () => { const {stdout} = await execa('git', ['rev-list', '--max-parents=0', 'HEAD']); return stdout; @@ -97,12 +104,6 @@ export const verifyCurrentBranchIsReleaseBranch = async releaseBranch => { } }; -export const tagList = async () => { - // Returns the list of tags, sorted by creation date in ascending order. - const {stdout} = await execa('git', ['tag', '--sort=creatordate']); - return stdout.split('\n'); -}; - export const isHeadDetached = async () => { try { // Command will fail with code 1 if the HEAD is detached. @@ -113,7 +114,7 @@ export const isHeadDetached = async () => { } }; -export const isWorkingTreeClean = async () => { +const isWorkingTreeClean = async () => { try { const {stdout: status} = await execa('git', ['status', '--porcelain']); if (status !== '') { @@ -182,7 +183,27 @@ export const fetch = async () => { await execa('git', ['fetch']); }; -export const tagExistsOnRemote = async tagName => { +const hasLocalBranch = async branch => { + try { + await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); + return true; + } catch { + return false; + } +}; + +export const defaultBranch = async () => { + for (const branch of ['main', 'master', 'gh-pages']) { + // eslint-disable-next-line no-await-in-loop + if (await hasLocalBranch(branch)) { + return branch; + } + } + + throw new Error('Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'); +}; + +const tagExistsOnRemote = async tagName => { try { const {stdout: revInfo} = await execa('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tagName}`]); @@ -202,26 +223,6 @@ export const tagExistsOnRemote = async tagName => { } }; -async function hasLocalBranch(branch) { - try { - await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]); - return true; - } catch { - return false; - } -} - -export const defaultBranch = async () => { - for (const branch of ['main', 'master', 'gh-pages']) { - // eslint-disable-next-line no-await-in-loop - if (await hasLocalBranch(branch)) { - return branch; - } - } - - throw new Error('Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'); -}; - export const verifyTagDoesNotExistOnRemote = async tagName => { if (await tagExistsOnRemote(tagName)) { throw new Error(`Git tag \`${tagName}\` already exists.`); @@ -233,13 +234,17 @@ export const commitLogFromRevision = async revision => { return stdout; }; +const push = async (tagArg = '--follow-tags') => { + await execa('git', ['push', tagArg]); +}; + export const pushGraceful = async remoteIsOnGitHub => { try { await push(); } catch (error) { if (remoteIsOnGitHub && error.stderr && error.stderr.includes('GH006')) { // Try to push tags only, when commits can't be pushed due to branch protection - await execa('git', ['push', '--tags']); + await push('--tags'); return {pushed: 'tags', reason: 'Branch protection: np can`t push the commits. Push them manually.'}; } @@ -247,10 +252,6 @@ export const pushGraceful = async remoteIsOnGitHub => { } }; -export const push = async () => { - await execa('git', ['push', '--follow-tags']); -}; - export const deleteTag = async tagName => { await execa('git', ['tag', '--delete', tagName]); }; @@ -267,8 +268,7 @@ const gitVersion = async () => { export const verifyRecentGitVersion = async () => { const installedVersion = await gitVersion(); - - Version.verifyRequirementSatisfied('git', installedVersion); + util.validateEngineVersionSatisfies('git', installedVersion); }; export const checkIfFileGitIgnored = async pathToFile => { diff --git a/source/index.js b/source/index.js index 28a263cb..c4aa6c30 100644 --- a/source/index.js +++ b/source/index.js @@ -4,7 +4,6 @@ import {execa} from 'execa'; import {deleteAsync} from 'del'; import Listr from 'listr'; import {merge, throwError, catchError, filter, finalize} from 'rxjs'; -import {readPackageUp} from 'read-pkg-up'; import hasYarn from 'has-yarn'; import hostedGitInfo from 'hosted-git-info'; import onetime from 'onetime'; @@ -64,7 +63,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { try { // Verify that the package's version has been bumped before deleting the last tag and commit. - if (versionInLatestTag === util.readPkg().version && versionInLatestTag !== pkg.version) { + if (versionInLatestTag === util.readPkg(rootDir).version && versionInLatestTag !== pkg.version) { await git.deleteTag(latestTag); await git.removeLastCommit(); } @@ -85,7 +84,9 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { } else { console.log('\nAborted!'); } - }, {minimumWait: 2000}); + }, {wait: 2000}); + + const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !npm.isExternalRegistry(pkg); const tasks = new Listr([ { @@ -97,13 +98,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { title: 'Git', task: () => gitTasks(options), }, - ], { - showSubtasks: false, - renderer: options.renderer ?? 'default', - }); - - if (runCleanup) { - tasks.add([ + ...runCleanup ? [ { title: 'Cleanup', enabled: () => !hasLockFile, @@ -136,11 +131,8 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { return exec('npm', [...args, '--engine-strict']); }, }, - ]); - } - - if (runTests) { - tasks.add([ + ] : [], + ...runTests ? [ { title: 'Running tests using npm', enabled: () => options.yarn === false, @@ -159,10 +151,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { }), ), }, - ]); - } - - tasks.add([ + ] : [], { title: 'Bumping version using Yarn', enabled: () => options.yarn === true, @@ -171,7 +160,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { let previewText = `[Preview] Command not executed: yarn version --new-version ${input}`; if (options.message) { - previewText += ` --message '${options.message.replace(/%s/g, input)}'`; + previewText += ` --message '${options.message.replaceAll('%s', input)}'`; } return `${previewText}.`; @@ -195,7 +184,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { let previewText = `[Preview] Command not executed: npm version ${input}`; if (options.message) { - previewText += ` --message '${options.message.replace(/%s/g, input)}'`; + previewText += ` --message '${options.message.replaceAll('%s', input)}'`; } return `${previewText}.`; @@ -211,10 +200,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { return exec('npm', args); }, }, - ]); - - if (options.runPublish) { - tasks.add([ + ...options.runPublish ? [ { title: `Publishing package using ${pkgManagerName}`, skip() { @@ -239,49 +225,37 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { ); }, }, - ]); - - const isExternalRegistry = npm.isExternalRegistry(pkg); - if (options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !isExternalRegistry) { - tasks.add([ - { - title: 'Enabling two-factor authentication', - skip() { - if (options.preview) { - const args = enable2fa.getEnable2faArgs(pkg.name, options); - return `[Preview] Command not executed: npm ${args.join(' ')}.`; - } - }, - task: (context, task) => enable2fa(task, pkg.name, {otp: context.otp}), + ...shouldEnable2FA ? [{ + title: 'Enabling two-factor authentication', + skip() { + if (options.preview) { + const args = enable2fa.getEnable2faArgs(pkg.name, options); + return `[Preview] Command not executed: npm ${args.join(' ')}.`; + } }, - ]); - } - } else { - publishStatus = 'SUCCESS'; - } - - tasks.add({ - title: 'Pushing tags', - async skip() { - if (!(await git.hasUpstream())) { - return 'Upstream branch not found; not pushing.'; - } + task: (context, task) => enable2fa(task, pkg.name, {otp: context.otp}), + }] : [], + ] : [], + { + title: 'Pushing tags', + async skip() { + if (!(await git.hasUpstream())) { + return 'Upstream branch not found; not pushing.'; + } - if (options.preview) { - return '[Preview] Command not executed: git push --follow-tags.'; - } + if (options.preview) { + return '[Preview] Command not executed: git push --follow-tags.'; + } - if (publishStatus === 'FAILED' && options.runPublish) { - return 'Couldn\'t publish package to npm; not pushing.'; - } - }, - async task() { - pushedObjects = await git.pushGraceful(isOnGitHub); + if (publishStatus === 'FAILED' && options.runPublish) { + return 'Couldn\'t publish package to npm; not pushing.'; + } + }, + async task() { + pushedObjects = await git.pushGraceful(isOnGitHub); + }, }, - }); - - if (options.releaseDraft) { - tasks.add({ + ...options.releaseDraft ? [{ title: 'Creating release draft on GitHub', enabled: () => isOnGitHub === true, skip() { @@ -289,8 +263,16 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { return '[Preview] GitHub Releases draft will not be opened in preview mode.'; } }, + // TODO: parse version outside of index task: () => releaseTaskHelper(options, pkg), - }); + }] : [], + ], { + showSubtasks: false, + renderer: options.renderer ?? 'default', + }); + + if (!options.runPublish) { + publishStatus = 'SUCCESS'; } await tasks.run(); @@ -299,7 +281,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { console.error(`\n${logSymbols.error} ${pushedObjects.reason}`); } - const {packageJson: newPkg} = await readPackageUp(); + const {pkg: newPkg} = await util.readPkg(); return newPkg; }; diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index 429746f1..383182ec 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -1,12 +1,14 @@ import {execa} from 'execa'; import {from, catchError} from 'rxjs'; -import semver from 'semver'; +import Version from '../version.js'; import handleNpmError from './handle-npm-error.js'; import {version as npmVersionCheck} from './util.js'; export const getEnable2faArgs = async (packageName, options) => { const npmVersion = await npmVersionCheck(); - const args = semver.satisfies(npmVersion, '>=9.0.0') ? ['access', 'set', 'mfa=publish', packageName] : ['access', '2fa-required', packageName]; + const args = new Version(npmVersion).satisfies('>=9.0.0') + ? ['access', 'set', 'mfa=publish', packageName] + : ['access', '2fa-required', packageName]; if (options && options.otp) { args.push('--otp', options.otp); diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index 7ec39c88..4188ea54 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -27,7 +27,7 @@ const handleNpmError = (error, task, message, executor) => { // Attempting to privately publish a scoped package without the correct npm plan // https://stackoverflow.com/a/44862841/10292952 if (error.code === 402 || error.stderr.includes('npm ERR! 402 Payment Required')) { - throw new Error('You cannot publish a privately scoped package without a paid plan. Did you mean to publish publicly?'); + throw new Error('You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'); } return throwError(() => error); diff --git a/source/npm/util.js b/source/npm/util.js index fec23fd9..792e1379 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -4,9 +4,14 @@ import {execa} from 'execa'; import pTimeout from 'p-timeout'; import ow from 'ow'; import npmName from 'npm-name'; -import chalk from 'chalk'; -import semver from 'semver'; +import chalk from 'chalk-template'; import Version from '../version.js'; +import * as util from '../util.js'; + +export const version = async () => { + const {stdout} = await execa('npm', ['--version']); + return stdout; +}; export const checkConnection = () => pTimeout( (async () => { @@ -33,18 +38,25 @@ export const username = async ({externalRegistry}) => { const {stdout} = await execa('npm', args); return stdout; } catch (error) { - throw new Error(/ENEEDAUTH/.test(error.stderr) + const message = /ENEEDAUTH/.test(error.stderr) ? 'You must be logged in. Use `npm login` and try again.' - : 'Authentication error. Use `npm whoami` to troubleshoot.'); + : 'Authentication error. Use `npm whoami` to troubleshoot.'; + throw new Error(message); } }; +export const isExternalRegistry = pkg => typeof pkg.publishConfig?.registry === 'string'; + export const collaborators = async pkg => { const packageName = pkg.name; ow(packageName, ow.string); const npmVersion = await version(); - const args = semver.satisfies(npmVersion, '>=9.0.0') ? ['access', 'list', 'collaborators', packageName, '--json'] : ['access', 'ls-collaborators', packageName]; + // TODO: Remove old command when targeting Node.js 18 + const args = new Version(npmVersion).satisfies('>=9.0.0') + ? ['access', 'list', 'collaborators', packageName, '--json'] + : ['access', 'ls-collaborators', packageName]; + if (isExternalRegistry(pkg)) { args.push('--registry', pkg.publishConfig.registry); } @@ -116,24 +128,17 @@ export const isPackageNameAvailable = async pkg => { return availability; }; -export const isExternalRegistry = pkg => typeof pkg.publishConfig === 'object' && typeof pkg.publishConfig.registry === 'string'; - -export const version = async () => { - const {stdout} = await execa('npm', ['--version']); - return stdout; -}; - export const verifyRecentNpmVersion = async () => { const npmVersion = await version(); - Version.verifyRequirementSatisfied('npm', npmVersion); + util.validateEngineVersionSatisfies('npm', npmVersion); }; -export const checkIgnoreStrategy = ({files}, rootDir) => { - const npmignoreExistsInPackageRootDir = pathExists(path.resolve(rootDir, '.npmignore')); +export const checkIgnoreStrategy = async ({files}, rootDir) => { + const npmignoreExistsInPackageRootDir = await pathExists(path.resolve(rootDir, '.npmignore')); if (!files && !npmignoreExistsInPackageRootDir) { - console.log(` - \n${chalk.bold.yellow('Warning:')} No ${chalk.bold.cyan('files')} field specified in ${chalk.bold.magenta('package.json')} nor is a ${chalk.bold.magenta('.npmignore')} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. + console.log(chalk` + \n{bold.yellow Warning:} No {bold.cyan files} field specified in {bold.magenta package.json} nor is a {bold.magenta .npmignore} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. `); } }; diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 50006c8d..4f9fc597 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -2,13 +2,13 @@ import process from 'node:process'; import Listr from 'listr'; import {execa} from 'execa'; import Version from './version.js'; +import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -import {getTagVersionPrefix} from './util.js'; const prerequisiteTasks = (input, pkg, options) => { const isExternalRegistry = npm.isExternalRegistry(pkg); - let newVersion = null; + let newVersion; const tasks = [ { @@ -25,7 +25,7 @@ const prerequisiteTasks = (input, pkg, options) => { enabled: () => options.yarn === true, async task() { const {stdout: yarnVersion} = await execa('yarn', ['--version']); - Version.verifyRequirementSatisfied('yarn', yarnVersion); + util.validateEngineVersionSatisfies('yarn', yarnVersion); }, }, { @@ -59,13 +59,15 @@ const prerequisiteTasks = (input, pkg, options) => { { title: 'Validate version', task() { - newVersion = Version.getAndValidateNewVersionFrom(input, pkg.version); + newVersion = input instanceof Version + ? input + : new Version(pkg.version).setFrom(input); }, }, { title: 'Check for pre-release version', task() { - if (!pkg.private && new Version(newVersion).isPrerelease() && !options.tag) { + if (!pkg.private && newVersion.isPrerelease() && !options.tag) { throw new Error('You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'); } }, @@ -75,7 +77,7 @@ const prerequisiteTasks = (input, pkg, options) => { async task() { await git.fetch(); - const tagPrefix = await getTagVersionPrefix(options); + const tagPrefix = await util.getTagVersionPrefix(options); await git.verifyTagDoesNotExistOnRemote(`${tagPrefix}${newVersion}`); }, diff --git a/source/pretty-version-diff.js b/source/pretty-version-diff.js deleted file mode 100644 index 059fa219..00000000 --- a/source/pretty-version-diff.js +++ /dev/null @@ -1,26 +0,0 @@ -import chalk from 'chalk'; -import Version from './version.js'; - -const prettyVersionDiff = (oldVersion, inc) => { - const newVersion = new Version(oldVersion).getNewVersionFrom(inc).split('.'); - oldVersion = oldVersion.split('.'); - let firstVersionChange = false; - const output = []; - - for (const [i, element] of newVersion.entries()) { - if ((element !== oldVersion[i] && !firstVersionChange)) { - output.push(`${chalk.dim.cyan(element)}`); - firstVersionChange = true; - } else if (element.indexOf('-') >= 1) { - let preVersion = []; - preVersion = element.split('-'); - output.push(`${chalk.dim.cyan(`${preVersion[0]}-${preVersion[1]}`)}`); - } else { - output.push(chalk.reset.dim(element)); - } - } - - return output.join(chalk.reset.dim('.')); -}; - -export default prettyVersionDiff; diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 2e71aca9..51f62058 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -4,18 +4,17 @@ import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; const releaseTaskHelper = async (options, pkg) => { - const newVersion = new Version(pkg.version).getNewVersionFrom(options.version); - let tag = await getTagVersionPrefix(options) + newVersion; - const isPreRelease = new Version(options.version).isPrerelease(); - if (isPreRelease) { - tag += await getPreReleasePrefix(options); - } + const newVersion = options.releaseDraftOnly + ? new Version(pkg.version) + : new Version(pkg.version).setFrom(options.version, {prereleasePrefix: await getPreReleasePrefix(options)}); + + const tag = await getTagVersionPrefix(options) + newVersion.toString(); const url = newGithubReleaseUrl({ repoUrl: options.repoUrl, tag, body: options.releaseNotes(tag), - isPrerelease: isPreRelease, + isPrerelease: newVersion.isPrerelease(), }); await open(url); diff --git a/source/ui.js b/source/ui.js index 6489ca31..29f1c980 100644 --- a/source/ui.js +++ b/source/ui.js @@ -4,11 +4,10 @@ import githubUrlFromGit from 'github-url-from-git'; import {htmlEscape} from 'escape-goat'; import isScoped from 'is-scoped'; import isInteractive from 'is-interactive'; +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'; -import Version from './version.js'; -import prettyVersionDiff from './pretty-version-diff.js'; const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch) => { const revision = fromLatestTag ? await git.latestTagOrFirstCommit() : await git.previousTagOrFirstCommit(); @@ -130,7 +129,7 @@ const ui = async (options, {pkg, rootDir}) => { const releaseBranch = options.branch; if (options.runPublish) { - npm.checkIgnoreStrategy(pkg, rootDir); + await npm.checkIgnoreStrategy(pkg, rootDir); const answerIgnoredFiles = await checkNewFilesAndDependencies(pkg, rootDir); if (!answerIgnoredFiles) { @@ -144,8 +143,9 @@ const ui = async (options, {pkg, rootDir}) => { if (options.releaseDraftOnly) { console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); } else { - const newVersion = options.version ? Version.getAndValidateNewVersionFrom(options.version, oldVersion) : undefined; - const versionText = chalk.dim(`(current: ${oldVersion}${newVersion ? `, next: ${prettyVersionDiff(oldVersion, newVersion)}` : ''}${chalk.dim(')')}`); + const versionText = options.version + ? chalk.dim(`(current: ${oldVersion}, next: ${new Version(oldVersion, options.version, {prereleasePrefix: await util.getPreReleasePrefix(options)}).format()})`) + : chalk.dim(`(current: ${oldVersion})`); console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${versionText}\n`); } @@ -170,6 +170,7 @@ const ui = async (options, {pkg, rootDir}) => { } } + // Non-interactive mode - return before prompting if (options.version) { return { ...options, @@ -214,44 +215,58 @@ const ui = async (options, {pkg, rootDir}) => { } } + const needsPrereleaseTag = answers => options.runPublish && (answers.version?.isPrerelease() || answers.customVersion?.isPrerelease()) && !options.tag; + const canBePublishedPublicly = options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !npm.isExternalRegistry(pkg); + const answers = await inquirer.prompt({ version: { type: 'list', - message: 'Select semver increment or specify new version', - pageSize: Version.SEMVER_INCREMENTS.length + 2, - choices: [...Version.SEMVER_INCREMENTS - .map(inc => ({ - name: `${inc} ${prettyVersionDiff(oldVersion, inc)}`, + message: 'Select SemVer increment or specify new version', + pageSize: SEMVER_INCREMENTS.length + 2, + choices: [ + ...SEMVER_INCREMENTS.map(inc => ({ // TODO: prerelease prefix here too + name: `${inc} ${new Version(oldVersion, inc).format()}`, value: inc, })), - new inquirer.Separator(), - { - name: 'Other (specify)', - value: null, - }], - filter: input => Version.isValidInput(input) ? new Version(oldVersion).getNewVersionFrom(input) : input, + 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, - filter: input => Version.isValidInput(input) ? new Version(pkg.version).getNewVersionFrom(input) : input, - validate(input) { - if (!Version.isValidInput(input)) { - return 'Please specify a valid semver, for example, `1.2.3`. See https://semver.org'; + when: answers => answers.version === undefined, + filter(input) { + if (SEMVER_INCREMENTS.includes(input)) { + throw new Error('Custom version should not be a SemVer increment.'); } - if (new Version(oldVersion).isLowerThanOrEqualTo(input)) { - return `Version must be greater than ${oldVersion}`; + 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 true; + return version; }, }, tag: { type: 'list', message: 'How should this pre-release version be tagged in npm?', - when: answers => options.runPublish && (Version.isPrereleaseOrIncrement(answers.customVersion) || Version.isPrereleaseOrIncrement(answers.version)) && !options.tag, + when: answers => needsPrereleaseTag(answers), async choices() { const existingPrereleaseTags = await npm.prereleaseTags(pkg.name); @@ -260,7 +275,7 @@ const ui = async (options, {pkg, rootDir}) => { new inquirer.Separator(), { name: 'Other (specify)', - value: null, + value: undefined, }, ]; }, @@ -268,7 +283,7 @@ const ui = async (options, {pkg, rootDir}) => { customTag: { type: 'input', message: 'Tag', - when: answers => options.runPublish && (Version.isPrereleaseOrIncrement(answers.customVersion) || Version.isPrereleaseOrIncrement(answers.version)) && !options.tag && !answers.tag, + when: answers => answers.tag === undefined && needsPrereleaseTag(answers), validate(input) { if (input.length === 0) { return 'Please specify a tag, for example, `next`.'; @@ -283,7 +298,7 @@ const ui = async (options, {pkg, rootDir}) => { }, publishScoped: { type: 'confirm', - when: isScoped(pkg.name) && options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !npm.isExternalRegistry(pkg), + when: isScoped(pkg.name) && canBePublishedPublicly, message: `This scoped repo ${chalk.bold.magenta(pkg.name)} hasn't been published. Do you want to publish it publicly?`, default: false, }, diff --git a/source/util.js b/source/util.js index 348bb13d..8d31e6ec 100644 --- a/source/util.js +++ b/source/util.js @@ -1,28 +1,33 @@ +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; import path from 'node:path'; import {readPackageUp} from 'read-pkg-up'; +import {parsePackage} from 'read-pkg'; import issueRegex from 'issue-regex'; import terminalLink from 'terminal-link'; import {execa} from 'execa'; import pMemoize from 'p-memoize'; import ow from 'ow'; import chalk from 'chalk'; -import {packageDirectory} from 'pkg-dir'; +import Version from './version.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -export const readPkg = async packagePath => { - packagePath = packagePath ? await packageDirectory(packagePath) : await packageDirectory(); - if (!packagePath) { +export const readPkg = async (packagePath = process.cwd()) => { + const packageResult = await readPackageUp({cwd: packagePath}); + + if (!packageResult) { throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); } - const {packageJson, path: pkgPath} = await readPackageUp({ - cwd: packagePath, - }); - - return {pkg: packageJson, rootDir: path.dirname(pkgPath)}; + return {pkg: packageResult.packageJson, rootDir: path.dirname(packageResult.path)}; }; +const _npRootDir = fileURLToPath(new URL('..', import.meta.url)); + +// Re-define `npRootDir` for trailing slash consistency +export const {pkg: npPkg, rootDir: npRootDir} = await readPkg(_npRootDir); + export const linkifyIssues = (url, message) => { if (!(url && terminalLink.isSupported)) { return message; @@ -59,12 +64,10 @@ export const getTagVersionPrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); try { - if (options.yarn) { - const {stdout} = await execa('yarn', ['config', 'get', 'version-tag-prefix']); - return stdout; - } + const {stdout} = options.yarn + ? await execa('yarn', ['config', 'get', 'version-tag-prefix']) + : await execa('npm', ['config', 'get', 'tag-version-prefix']); - const {stdout} = await execa('npm', ['config', 'get', 'tag-version-prefix']); return stdout; } catch { return 'v'; @@ -84,16 +87,16 @@ export const getNewFiles = async rootDir => { }; export const getNewDependencies = async (newPkg, rootDir) => { - let oldPkg; + let oldPkgFile; try { - oldPkg = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); + oldPkgFile = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); } catch { // Handle first time publish return Object.keys(newPkg.dependencies ?? {}); } - oldPkg = JSON.parse(oldPkg); + const oldPkg = parsePackage(oldPkgFile); const newDependencies = []; @@ -110,22 +113,18 @@ export const getPreReleasePrefix = pMemoize(async options => { ow(options, ow.object.hasKeys('yarn')); try { - if (options.yarn) { - const {stdout} = await execa('yarn', ['config', 'get', 'preId']); - if (stdout !== 'undefined') { - return stdout; - } - - return ''; - } - - const {stdout} = await execa('npm', ['config', 'get', 'preId']); - if (stdout !== 'undefined') { - return stdout; - } + const packageManager = options.yarn ? 'yarn' : 'npm'; + const {stdout} = await execa(packageManager, ['config', 'get', 'preid']); - return ''; + return stdout === 'undefined' ? '' : stdout; } catch { return ''; } }); + +export const validateEngineVersionSatisfies = (engine, version) => { + const engineRange = npPkg.engines[engine]; + if (!new Version(version).satisfies(engineRange)) { + throw new Error(`\`np\` requires ${engine} ${engineRange}`); + } +}; diff --git a/source/version.js b/source/version.js index c395f268..82b21ce4 100644 --- a/source/version.js +++ b/source/version.js @@ -1,84 +1,190 @@ import semver from 'semver'; -import {readPackageUp} from 'read-pkg-up'; +import {template as chalk} from 'chalk-template'; -const {packageJson: pkg} = await readPackageUp(); +/** @type {string[]} Allowed `SemVer` release types. */ +export const SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; +export const SEMVER_INCREMENTS_LIST = SEMVER_INCREMENTS.join(', '); +const SEMVER_INCREMENTS_LIST_LAST_OR = `${SEMVER_INCREMENTS.slice(0, -1).join(', ')}, or ${SEMVER_INCREMENTS.slice(-1)}`; + +/** @typedef {semver.SemVer} SemVerInstance */ +/** @typedef {semver.ReleaseType} SemVerIncrement */ +/** @typedef {import('chalk').ColorName | import('chalk').ModifierName} ColorName */ + +/** @param {string} input @returns {input is SemVerIncrement} */ +const isSemVerIncrement = input => SEMVER_INCREMENTS.includes(input); + +/** @param {string} input */ +const isInvalidSemVerVersion = input => Boolean(!semver.valid(input)); + +/** +Formats the first difference between two versions to the given `diffColor`. Useful for `prerelease` diffs. + +@param {string[]} current @param {string[]} previous @param {ColorName} diffColor +*/ +const formatFirstDifference = (current, previous, diffColor) => { + const firstDifferenceIndex = current.findIndex((part, i) => previous.at(i) !== part); + current[firstDifferenceIndex] = `{${diffColor} ${current.at(firstDifferenceIndex)}}`; + return current.join('.'); +}; export default class Version { - constructor(version) { - this.version = version; + /** @type {SemVerInstance} */ + #version; + + /** @type {SemVerIncrement | undefined} */ + #diff = undefined; + + /** @type {string | undefined} */ + #prereleasePrefix = undefined; + + toString() { + return this.#version.version; } - isPrerelease() { - return Boolean(semver.prerelease(this.version)); + /** + Sets `this.#version` to the given version. + + @param {string} version + @throws If `version` is an invalid `SemVer` version. + */ + #trySetVersion(version) { + this.#version = semver.parse(version); + + if (this.#version === null) { + throw new Error(`Version ${version} should be a valid SemVer version.`); + } } - satisfies(range) { - Version.validate(this.version); - return semver.satisfies(this.version, range, { - includePrerelease: true, - }); + /** + @param {string} version - A valid `SemVer` version. + @param {SemVerIncrement} [increment] - Optionally increment `version`. + @param {object} [options] + @param {string} [options.prereleasePrefix] - A prefix to use for `prerelease` versions. + */ + constructor(version, increment, {prereleasePrefix} = {}) { + this.#prereleasePrefix = prereleasePrefix; + this.#trySetVersion(version); + + if (increment) { + if (!isSemVerIncrement(increment)) { + throw new Error(`Increment ${increment} should be one of ${SEMVER_INCREMENTS_LIST_LAST_OR}.`); + } + + this.setFrom(increment); + } } - getNewVersionFrom(input) { - Version.validate(this.version); - if (!Version.isValidInput(input)) { - throw new Error(`Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`); + /** + Sets a new version based on `input`. If `input` is a valid `SemVer` increment, the current version will be incremented by that amount. If `input` is a valid `SemVer` version, the current version will be set to `input` if it is greater than the current version. + + @param {string | SemVerIncrement} input - A new valid `SemVer` version or a `SemVer` increment to increase the current version by. + @param {object} [options] + @param {string} [options.prereleasePrefix] - A prefix to use for `prerelease` versions. + @throws If `input` is not a valid `SemVer` version or increment, or if `input` is a valid `SemVer` version but is not greater than the current version. + */ + setFrom(input, {prereleasePrefix = ''} = {}) { + this.#prereleasePrefix ??= prereleasePrefix; + const previousVersion = this.toString(); + + if (isSemVerIncrement(input)) { + this.#version.inc(input, this.#prereleasePrefix); + } else { + if (isInvalidSemVerVersion(input)) { + throw new Error(`New version ${input} should either be one of ${SEMVER_INCREMENTS_LIST}, or a valid SemVer version.`); + } + + if (this.#isGreaterThanOrEqualTo(input)) { + throw new Error(`New version ${input} should be higher than current version ${this.toString()}.`); + } + + this.#trySetVersion(input); } - return Version.SEMVER_INCREMENTS.includes(input) ? semver.inc(this.version, input) : input; + // Set `this.#diff` to format version diffs + this.#diff = semver.diff(previousVersion, this.#version); + return this; } - isGreaterThanOrEqualTo(otherVersion) { - Version.validate(this.version); - Version.validate(otherVersion); + /** + Formats the current version with `options.color`, pretty-printing the version's diff with `options.diffColor` if possible. - return semver.gte(otherVersion, this.version); - } + If the current version has never been changed, providing `options.previousVersion` will allow pretty-printing the diff. It must be provided to format diffs between `prerelease` versions. - isLowerThanOrEqualTo(otherVersion) { - Version.validate(this.version); - Version.validate(otherVersion); + @param {object} options + @param {ColorName} [options.color = 'dim'] + @param {ColorName} [options.diffColor = 'cyan'] + @param {string} [options.prereleasePrefix] + @returns {string} A color-formatted version string. + */ + format({color = 'dim', diffColor = 'cyan', previousVersion} = {}) { + if (typeof previousVersion === 'string') { + const previousSemver = semver.parse(previousVersion); - return semver.lte(otherVersion, this.version); - } + if (previousSemver === null) { + throw new Error(`Previous version ${previousVersion} should be a valid SemVer version.`); + } - static SEMVER_INCREMENTS = ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']; - static PRERELEASE_VERSIONS = ['prepatch', 'preminor', 'premajor', 'prerelease']; + previousVersion = previousSemver; + } - static isPrereleaseOrIncrement = input => new Version(input).isPrerelease() || Version.PRERELEASE_VERSIONS.includes(input); + if (!this.#diff) { + if (!previousVersion) { + return chalk(`{${color} ${this.toString()}}`); + } - static isValidVersion = input => Boolean(semver.valid(input)); + this.#diff = semver.diff(previousVersion, this.#version); + } - static isValidInput = input => Version.SEMVER_INCREMENTS.includes(input) || Version.isValidVersion(input); + const {major, minor, patch, prerelease} = this.#version; + const previousPrerelease = semver.prerelease(previousVersion); - static validate(version) { - if (!Version.isValidVersion(version)) { - throw new Error('Version should be a valid semver version.'); + if (prerelease && previousPrerelease) { + const prereleaseDiff = formatFirstDifference(prerelease, previousPrerelease, diffColor); + return chalk(`{${color} ${major}.${minor}.${patch}-${prereleaseDiff}}`); } + + /* eslint-disable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ + return ( + this.#diff === 'major' ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}}`) : + this.#diff === 'minor' ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}}`) : + this.#diff === 'patch' ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}}`) : + this.#diff === 'premajor' ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : + this.#diff === 'preminor' ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : + this.#diff === 'prepatch' ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}-{${diffColor} ${prerelease.join('.')}}}`) : + this.#diff === 'prerelease' ? chalk(`{${color} ${major}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : '' + ); + /* eslint-enable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ } - static verifyRequirementSatisfied(dependency, version) { - if (!pkg.engines?.node) { - throw new Error('Please include a `engines.node` field in your package.json'); - } + /** + If the current version satisifes the given `SemVer` range. - const versionRange = pkg.engines?.[dependency]; - if (!versionRange) { - return; + @param {string} range + @throws If `range` is invalid. + */ + satisfies(range) { + if (!semver.validRange(range)) { + throw new Error(`Range ${range} is not a valid SemVer range.`); } - if (!new Version(version).satisfies(versionRange)) { - throw new Error(`Please upgrade to ${dependency}${versionRange}`); - } + return semver.satisfies(this.#version, range, { + includePrerelease: true, + }); } - static getAndValidateNewVersionFrom(input, version) { - const newVersion = new Version(version).getNewVersionFrom(input); + /** + If the current version has any `prerelease` components. + */ + isPrerelease() { + return Boolean(semver.prerelease(this.#version)); + } - if (new Version(version).isLowerThanOrEqualTo(newVersion)) { - throw new Error(`New version \`${newVersion}\` should be higher than current version \`${version}\``); - } + /** + If the current version is the same as or higher than the given version. - return newVersion; + @param {string} otherVersion + */ + #isGreaterThanOrEqualTo(otherVersion) { + return semver.gte(this.#version, otherVersion); } } diff --git a/test/_helpers/integration-test.d.ts b/test/_helpers/integration-test.d.ts new file mode 100644 index 00000000..5bbd4f81 --- /dev/null +++ b/test/_helpers/integration-test.d.ts @@ -0,0 +1,32 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {Execa$} from 'execa'; + +type Context = { + firstCommitMessage: string; + getCommitMessage: (sha: string) => Promise; + createFile: (file: string, content?: string) => Promise; + commitNewFile: () => Promise<{ + sha: string; + commitMessage: string; + }>; +}; + +type CommandsFnParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDir: string; +}]; + +type AssertionsFnParameters = [{ + t: ExecutionContext; + testedModule: MockType; + $$: Execa$; + temporaryDir: string; +}]; + +export type CreateFixtureMacro = Macro<[ + commands: (...arguments_: CommandsFnParameters) => Promise, + assertions: (...arguments_: AssertionsFnParameters) => Promise, +], Context>; + +export function _createFixture(source: string): CreateFixtureMacro; diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js new file mode 100644 index 00000000..1e051589 --- /dev/null +++ b/test/_helpers/integration-test.js @@ -0,0 +1,66 @@ +/* eslint-disable ava/no-ignored-test-files */ +import crypto from 'node:crypto'; +import path from 'node:path'; +import fs from 'fs-extra'; +import test from 'ava'; +import esmock from 'esmock'; +import {$, execa} from 'execa'; +import {temporaryDirectoryTask} from 'tempy'; + +const createEmptyGitRepo = async ($$, temporaryDir) => { + const firstCommitMessage = '"init1"'; + + await $$`git init`; + + // `git tag` needs an initial commit + await fs.createFile(path.resolve(temporaryDir, 'temp')); + await $$`git add temp`; + await $$`git commit -m ${firstCommitMessage}`; + await $$`git rm temp`; + await $$`git commit -m "init2"`; + + return firstCommitMessage; +}; + +export const createIntegrationTest = async (t, assertions) => { + await temporaryDirectoryTask(async temporaryDir => { + const $$ = $({cwd: temporaryDir}); + + t.context.firstCommitMessage = await createEmptyGitRepo($$, temporaryDir); + + // From https://stackoverflow.com/a/3357357/10292952 + t.context.getCommitMessage = async sha => { + const {stdout: commitMessage} = await $$`git log --format=%B -n 1 ${sha}`; + return commitMessage.trim(); + }; + + t.context.createFile = async (file, content = '') => fs.outputFile(path.resolve(temporaryDir, file), content); + + t.context.commitNewFile = async () => { + await t.context.createFile(`new-${crypto.randomUUID()}`); + await $$`git add .`; + await $$`git commit -m "added"`; + + const {stdout: lastCommitSha} = await $$`git rev-parse --short HEAD`; + + return { + sha: lastCommitSha, + commitMessage: await t.context.getCommitMessage(lastCommitSha), + }; + }; + + await assertions({$$, temporaryDir}); + }); +}; + +export const _createFixture = source => test.macro(async (t, commands, assertions) => { + await createIntegrationTest(t, async ({$$, temporaryDir}) => { + const testedModule = await esmock(source, {}, { + 'node:process': {cwd: () => temporaryDir}, + execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + }); + + await commands({t, $$, temporaryDir}); + await assertions({t, testedModule, $$, temporaryDir}); + }); +}); diff --git a/test/fixtures/listr-renderer.js b/test/_helpers/listr-renderer.js similarity index 100% rename from test/fixtures/listr-renderer.js rename to test/_helpers/listr-renderer.js diff --git a/test/_helpers/listr.js b/test/_helpers/listr.js new file mode 100644 index 00000000..84b10b02 --- /dev/null +++ b/test/_helpers/listr.js @@ -0,0 +1,20 @@ +import {SilentRenderer} from './listr-renderer.js'; + +export const run = async listr => { + listr.setRenderer(SilentRenderer); + await listr.run(); +}; + +export const assertTaskFailed = (t, taskTitle) => { + const task = SilentRenderer.tasks.find(task => task.title === taskTitle); + t.true(task.hasFailed(), `Task '${taskTitle}' did not fail!`); +}; + +export const assertTaskDisabled = (t, taskTitle) => { + const task = SilentRenderer.tasks.find(task => task.title === taskTitle); + t.true(!task.isEnabled(), `Task '${taskTitle}' was enabled!`); +}; + +export const assertTaskDoesntExist = (t, taskTitle) => { + t.true(SilentRenderer.tasks.every(task => task.title !== taskTitle), `Task '${taskTitle}' exists!`); +}; diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js new file mode 100644 index 00000000..9a4c1d92 --- /dev/null +++ b/test/_helpers/mock-inquirer.js @@ -0,0 +1,223 @@ +import esmock from 'esmock'; +import is from '@sindresorhus/is'; +import stripAnsi from 'strip-ansi'; +import mapObject from 'map-obj'; + +/** @typedef {import('ava').ExecutionContext>} ExecutionContext */ +/** @typedef {string | boolean} ShortAnswer */ +/** @typedef {Record<'input' | 'error', string> | Record<'choice', string> | Record<'confirm', boolean>} LongAnswer */ +/** @typedef {ShortAnswer | LongAnswer} Answer */ +/** @typedef {Record} Answers */ +/** @typedef {import('inquirer').DistinctQuestion & {name?: never}} Prompt */ + +/** +Mocks `inquirer.prompt` and answers each prompt in the program with the provided `inputAnswers`. + +This only handles prompts of type `input`, `list`, and `confirm`. If other prompt types are added, they must be implemented here. + +Logs for debugging are outputted on test failure. + +@see https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984 + +@param {object} o Test input and actual prompts +@param {ExecutionContext} o.t +@param {Answers} o.inputAnswers Test input +@param {Record | Prompt[]} o.prompts Actual prompts +*/ +const mockPrompt = async ({t, inputAnswers, prompts}) => { + const answers = {}; + + // Ensure `prompts` is an object + if (Array.isArray(prompts)) { + const promptsObject = {}; + + for (const prompt of prompts) { + promptsObject[prompt.name] = prompt; + } + + prompts = promptsObject; + } + + t.log('prompts:', Object.keys(prompts)); + + /* eslint-disable no-await-in-loop */ + for (const [name, prompt] of Object.entries(prompts)) { + if (prompt.when !== undefined) { + if (is.boolean(prompt.when) && !prompt.when) { + t.log(`skipping prompt '${name}'`); + continue; + } + + if (is.function_(prompt.when) && !prompt.when(answers)) { + t.log(`skipping prompt '${name}'`); + continue; + } + } + + t.log(`getting input for prompt '${name}'`); + + const setValue = value => { + if (prompt.validate) { + const result = prompt.validate(value); + + if (result !== true) { + if (is.string(result)) { + throw new Error(result); + } + + if (result === false) { + throw new Error('You must provide a valid value'); + } + } + } + + if (is.string(value)) { + t.log(`filtering value '${value}' for prompt '${name}'`); + } else { + t.log(`filtering value for prompt '${name}':`, value); + } + + answers[name] = prompt.filter + ? prompt.filter(value) // eslint-disable-line unicorn/no-array-callback-reference + : value; + + t.log(`got value '${answers[name]}' for prompt '${name}'`); + }; + + /** @param {Answer} input */ + const chooseValue = async input => { + t.is(prompt.type, 'list'); + let choices; + + if (is.asyncFunction(prompt.choices)) { + choices = await prompt.choices(answers); + } else if (is.function_(prompt.choices)) { + choices = prompt.choices(answers); + } else { + choices = prompt.choices; + } + + t.log(`choices for prompt '${name}':`, choices); + + const value = choices.find(choice => { + if (is.object(choice)) { + return choice.name && stripAnsi(choice.name).startsWith(input.choice ?? input); + } + + if (is.string(choice)) { + return stripAnsi(choice).startsWith(input.choice ?? input); + } + + return false; + }); + + // `value.value` could exist but literally be `undefined` + setValue(Object.hasOwn(value, 'value') ? value.value : value); + }; + + const input = inputAnswers[name]; + + if (is.undefined(input)) { + t.fail(`Expected input for prompt '${name}'.`); + continue; + } + + if (is.string(input)) { + t.log(`found input for prompt '${name}': '${input}'`); + } else { + t.log(`found input for prompt '${name}':`, input); + } + + /** @param {Answer} input */ + const handleInput = async input => { + if (is.string(input)) { + if (['input'].includes(prompt.type)) { + setValue(input); + } else if (['list'].includes(prompt.type)) { + return chooseValue(input); + } else { + t.fail('Incorrect input type'); + } + + return; + } + + if (input.input !== undefined) { + t.is(prompt.type, 'input'); + setValue(input.input); + return; + } + + if (input.choice !== undefined) { + await chooseValue(input); + return; + } + + if (is.boolean(input.confirm) || is.boolean(input)) { + t.is(prompt.type, 'confirm'); + setValue(input.confirm ?? input); + } + }; + + // Multiple inputs for the given prompt + if (is.array(input)) { + for (const attempt of input) { + if (attempt.error) { + await t.throwsAsync( + handleInput(attempt), + {message: attempt.error}, + ); + } else { + await handleInput(attempt); + } + } + } + + await handleInput(input); + } + /* eslint-enable no-await-in-loop */ + + return answers; +}; + +/** +Fixes relative module paths for use with `esmock`. Allows specifiying the same relative location in test files as in source files. +@param {import('esmock').MockMap} mocks +*/ +const fixRelativeMocks = mocks => mapObject(mocks, (key, value) => [key.replace('./', '../../source/'), value]); + +/** +Mocks `inquirer` for testing `source/ui.js`. + +@param {object} o Test input and optional global mocks +@param {ExecutionContext} o.t +@param {Answers} o.answers Test input +@param {import('esmock').MockMap} [o.mocks] Optional global mocks +*/ +export const mockInquirer = async ({t, answers, mocks = {}}) => { + /** @type {string[]} */ + const logs = []; + + /** @type {import('../../source/ui.js')} */ + const ui = await esmock('../../source/ui.js', import.meta.url, { + inquirer: { + async prompt(prompts) { + let uiAnswers = {}; + + const assertions = await t.try(async tt => { + uiAnswers = await mockPrompt({t: tt, inputAnswers: answers, prompts}); + }); + + assertions.commit({retainLogs: !assertions.passed}); + return uiAnswers; + }, + }, + }, { + ...fixRelativeMocks(mocks), + import: { + console: {log: (...args) => logs.push(...args)}, + }, + }); + + return {ui, logs}; +}; diff --git a/test/_helpers/stub-execa.d.ts b/test/_helpers/stub-execa.d.ts new file mode 100644 index 00000000..ccf04da6 --- /dev/null +++ b/test/_helpers/stub-execa.d.ts @@ -0,0 +1,14 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {ExecaReturnValue} from 'execa'; + +type AssertionsFnParameters = [{ + t: ExecutionContext; + testedModule: MockType; +}]; + +export type CreateFixtureMacro = Macro<[ + commands: ExecaReturnValue[], + assertions: (...arguments_: AssertionsFnParameters) => Promise, +]>; + +export function _createFixture(source: string, importMeta: string): CreateFixtureMacro; diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js new file mode 100644 index 00000000..f5c0973e --- /dev/null +++ b/test/_helpers/stub-execa.js @@ -0,0 +1,50 @@ +/* eslint-disable ava/no-ignored-test-files */ +import test from 'ava'; +import esmock from 'esmock'; +import sinon from 'sinon'; +import {execa} from 'execa'; + +/** +Stubs `execa` to return a specific result when called with the given commands. + +A command passes if its exit code is 0, or if there's no exit code and no stderr. + +Resolves or throws the given result. + +@param {import('execa').ExecaReturnValue[]} commands +*/ +const makeExecaStub = commands => { + const stub = sinon.stub(); + + for (const result of commands) { + const [command, ...commandArgs] = result.command.split(' '); + + const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); + + if (passes) { + stub.withArgs(command, commandArgs).resolves(result); + } else { + stub.withArgs(command, commandArgs).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message + } + } + + return stub; +}; + +const stubExeca = commands => { + const execaStub = makeExecaStub(commands); + + return { + execa: { + async execa(...args) { + execaStub.resolves(execa(...args)); + return execaStub(...args); + }, + }, + }; +}; + +export const _createFixture = (source, importMeta) => test.macro(async (t, commands, assertions) => { + const testedModule = await esmock(source, importMeta, {}, stubExeca(commands)); + await assertions({t, testedModule}); +}); diff --git a/test/_helpers/util.js b/test/_helpers/util.js new file mode 100644 index 00000000..60600052 --- /dev/null +++ b/test/_helpers/util.js @@ -0,0 +1,12 @@ +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; + +export const runIfExists = async (func, ...args) => { + if (typeof func === 'function') { + await func(...args); + } +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const getFixture = fixture => path.resolve(__dirname, '..', 'fixtures', ...fixture.split('/')); diff --git a/test/_helpers/verify-cli.d.ts b/test/_helpers/verify-cli.d.ts new file mode 100644 index 00000000..8b695ddc --- /dev/null +++ b/test/_helpers/verify-cli.d.ts @@ -0,0 +1,10 @@ +import type {Macro, ExecutionContext} from 'ava'; + +type VerifyCliMacro = Macro<[ + binPath: string, + args: string | string[], + expectedLines: string[], +], Record>; + +export const cliPasses: VerifyCliMacro; +export const cliFails: VerifyCliMacro; diff --git a/test/_helpers/verify-cli.js b/test/_helpers/verify-cli.js new file mode 100644 index 00000000..9d3985a8 --- /dev/null +++ b/test/_helpers/verify-cli.js @@ -0,0 +1,16 @@ +/* eslint-disable ava/no-ignored-test-files */ +import test from 'ava'; +import {execa} from 'execa'; + +const trim = stdout => stdout.split('\n').map(line => line.trim()); + +const _verifyCli = shouldPass => test.macro(async (t, binPath, args, expectedLines) => { + const {exitCode, stdout} = await execa(binPath, [args].flat(), {reject: false}); + const receivedLines = trim(stdout); + + t.deepEqual(receivedLines, expectedLines, 'CLI output different than expectations!'); + t.is(exitCode, shouldPass ? 0 : 1, 'CLI exited with the wrong exit code!'); +}); + +export const cliPasses = _verifyCli(true); +export const cliFails = _verifyCli(false); diff --git a/test/_utils.js b/test/_utils.js deleted file mode 100644 index c87b122f..00000000 --- a/test/_utils.js +++ /dev/null @@ -1,58 +0,0 @@ -import esmock from 'esmock'; -import sinon from 'sinon'; -import {execa} from 'execa'; -import {SilentRenderer} from './fixtures/listr-renderer.js'; - -const makeExecaStub = commands => { - const stub = sinon.stub(); - - for (const result of commands) { - const [command, ...commandArgs] = result.command.split(' '); - - // Command passes if the exit code is 0, or if there's no exit code and no stderr. - const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); - - if (passes) { - stub.withArgs(command, commandArgs).resolves(result); - } else { - stub.withArgs(command, commandArgs).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message - } - } - - return stub; -}; - -export const _stubExeca = source => async commands => { - const execaStub = makeExecaStub(commands); - - return esmock(source, {}, { - execa: { - execa: async (...args) => execaStub.resolves(execa(...args))(...args), - }, - }); -}; - -export const run = async listr => { - listr.setRenderer(SilentRenderer); - await listr.run(); -}; - -export const assertTaskFailed = (t, taskTitle) => { - const task = SilentRenderer.tasks.find(task => task.title === taskTitle); - t.true(task.hasFailed(), `'${taskTitle}' did not fail!`); -}; - -export const assertTaskDisabled = (t, taskTitle) => { - const task = SilentRenderer.tasks.find(task => task.title === taskTitle); - t.true(!task.isEnabled(), `'${taskTitle}' was enabled!`); -}; - -export const assertTaskDoesntExist = (t, taskTitle) => { - t.true(SilentRenderer.tasks.every(task => task.title !== taskTitle), `'${taskTitle}' exists!`); -}; - -export const runIfExists = async (func, ...args) => { - if (typeof func === 'function') { - await func(...args); - } -}; diff --git a/test/cli.js b/test/cli.js new file mode 100644 index 00000000..c898fd67 --- /dev/null +++ b/test/cli.js @@ -0,0 +1,44 @@ +import path from 'node:path'; +import test from 'ava'; +import {npPkg, npRootDir as rootDir} from '../source/util.js'; +import {cliPasses} from './_helpers/verify-cli.js'; + +const cli = path.resolve(rootDir, 'source', 'cli-implementation.js'); + +test('flags: --help', cliPasses, cli, '--help', [ + '', + 'A better `npm publish`', + '', + 'Usage', + '$ np ', + '', + 'Version can be:', + 'patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3', + '', + 'Options', + '--any-branch Allow publishing from any branch', + '--branch Name of the release branch (default: main | master)', + '--no-cleanup Skips cleanup of node_modules', + '--no-tests Skips tests', + '--yolo Skips cleanup and testing', + '--no-publish Skips publishing', + '--preview Show tasks without actually executing them', + '--tag Publish under a given dist-tag', + '--no-yarn Don\'t use Yarn', + '--contents Subdirectory to publish', + '--no-release-draft Skips opening a GitHub release draft', + '--release-draft-only Only opens a GitHub release draft for the latest published version', + '--test-script Name of npm run script to run tests before publishing (default: test)', + '--no-2fa Don\'t enable 2FA on new packages (not recommended)', + '--message Version bump commit message, \'%s\' will be replaced with version (default: \'%s\' with npm and \'v%s\' with yarn)', + '', + 'Examples', + '$ np', + '$ np patch', + '$ np 1.0.2', + '$ np 1.0.2-beta.3 --tag=beta', + '$ np 1.0.2-beta.3 --tag=beta --contents=dist', + '', +]); + +test('flags: --version', cliPasses, cli, '--version', [npPkg.version]); diff --git a/test/config.js b/test/config.js index de56e1d9..5f2f6bd8 100644 --- a/test/config.js +++ b/test/config.js @@ -1,6 +1,5 @@ import path from 'node:path'; import test from 'ava'; -import sinon from 'sinon'; import esmock from 'esmock'; const testedModulePath = '../source/config.js'; @@ -8,13 +7,13 @@ const testedModulePath = '../source/config.js'; const getFixture = fixture => path.resolve('test', 'fixtures', 'config', fixture); const getFixtures = fixtures => fixtures.map(fixture => getFixture(fixture)); -const getConfigsWhenGlobalBinaryIsUsed = async homedirStub => { +const getConfigsWhenGlobalBinaryIsUsed = async homedir => { const pathsPkgDir = getFixtures(['pkg-dir', 'local1', 'local2', 'local3']); const promises = pathsPkgDir.map(async pathPkgDir => { const getConfig = await esmock(testedModulePath, { 'is-installed-globally': true, - 'node:os': {homedir: homedirStub}, + 'node:os': {homedir: () => homedir}, }); return getConfig(pathPkgDir); }); @@ -37,8 +36,7 @@ const getConfigsWhenLocalBinaryIsUsed = async pathPkgDir => { }; const useGlobalBinary = test.macro(async (t, homedir, source) => { - const homedirStub = sinon.stub().returns(getFixture(homedir)); - const configs = await getConfigsWhenGlobalBinaryIsUsed(homedirStub); + const configs = await getConfigsWhenGlobalBinaryIsUsed(getFixture(homedir)); for (const config of configs) { t.deepEqual(config, {source}); diff --git a/test/fixtures/files/files-and-npmignore/source/index.test-d.ts b/test/fixtures/files/files-and-npmignore/source/index.test-d.ts index 448777bb..cd87de16 100644 --- a/test/fixtures/files/files-and-npmignore/source/index.test-d.ts +++ b/test/fixtures/files/files-and-npmignore/source/index.test-d.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import {foo, bar} from '.'; +import {foo, bar} from './index.js'; expectType(foo()); expectType(bar()); diff --git a/test/fixtures/files/gitignore/.gitignore b/test/fixtures/files/gitignore/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/test/fixtures/files/gitignore/.gitignore @@ -0,0 +1 @@ +dist diff --git a/test/fixtures/files/gitignore/gitignore b/test/fixtures/files/gitignore/gitignore deleted file mode 100644 index a01644f5..00000000 --- a/test/fixtures/files/gitignore/gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# This file is renamed to `.gitignore` in the test -# This is not named `.gitignore` to allow `dist/` to be committed -dist diff --git a/test/fixtures/files/gitignore/index.test-d.ts b/test/fixtures/files/gitignore/index.test-d.ts index 650c167e..85f60f8e 100644 --- a/test/fixtures/files/gitignore/index.test-d.ts +++ b/test/fixtures/files/gitignore/index.test-d.ts @@ -1,4 +1,4 @@ import {expectType} from 'tsd'; -import foo from '.'; +import foo from './index.js'; expectType(foo()); diff --git a/test/fixtures/files/npmignore-and-gitignore/.gitignore b/test/fixtures/files/npmignore-and-gitignore/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/test/fixtures/files/npmignore-and-gitignore/.gitignore @@ -0,0 +1 @@ +dist diff --git a/test/fixtures/files/npmignore-and-gitignore/gitignore b/test/fixtures/files/npmignore-and-gitignore/gitignore deleted file mode 100644 index a01644f5..00000000 --- a/test/fixtures/files/npmignore-and-gitignore/gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# This file is renamed to `.gitignore` in the test -# This is not named `.gitignore` to allow `dist/` to be committed -dist diff --git a/test/fixtures/files/npmignore-and-gitignore/script/build.js b/test/fixtures/files/npmignore-and-gitignore/script/build.js index 8a2c0921..94c94ba5 100644 --- a/test/fixtures/files/npmignore-and-gitignore/script/build.js +++ b/test/fixtures/files/npmignore-and-gitignore/script/build.js @@ -1,2 +1 @@ -/* eslint-disable unicorn/no-empty-file */ // ... yada yada yada diff --git a/test/fixtures/files/npmignore/index.test-d.ts b/test/fixtures/files/npmignore/index.test-d.ts index 650c167e..85f60f8e 100644 --- a/test/fixtures/files/npmignore/index.test-d.ts +++ b/test/fixtures/files/npmignore/index.test-d.ts @@ -1,4 +1,4 @@ import {expectType} from 'tsd'; -import foo from '.'; +import foo from './index.js'; expectType(foo()); diff --git a/test/git-tasks.js b/test/git-tasks.js deleted file mode 100644 index c39963d3..00000000 --- a/test/git-tasks.js +++ /dev/null @@ -1,204 +0,0 @@ -import test from 'ava'; -import {SilentRenderer} from './fixtures/listr-renderer.js'; -import { - _stubExeca, - run, - assertTaskFailed, - assertTaskDoesntExist, -} from './_utils.js'; - -/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ -const stubExeca = _stubExeca('../source/git-tasks.js'); - -test.afterEach(() => { - SilentRenderer.clearTasks(); -}); - -test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', async t => { - const gitTasks = await stubExeca([{ - command: 'git symbolic-ref --short HEAD', - stdout: 'feature', - }]); - - await t.throwsAsync( - run(gitTasks({branch: 'master'})), - {message: 'Not on `master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, - ); - - assertTaskFailed(t, 'Check current branch'); -}); - -test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', async t => { - const gitTasks = await stubExeca([{ - command: 'git symbolic-ref --short HEAD', - stdout: 'feature', - }]); - - await t.throwsAsync( - run(gitTasks({branch: 'release'})), - {message: 'Not on `release` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, - ); - - assertTaskFailed(t, 'Check current branch'); -}); - -test.serial('should not fail when current branch not master and publishing from any branch permitted', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'feature', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - exitCode: 0, - }, - { - command: 'git fetch --dry-run', - exitCode: 0, - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - stdout: '0', - }, - ]); - - await t.notThrowsAsync( - run(gitTasks({anyBranch: true})), - ); - - assertTaskDoesntExist(t, 'Check current branch'); -}); - -test.serial('should fail when local working tree modified', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: 'M source/git-tasks.js', - }, - ]); - - await t.throwsAsync( - run(gitTasks({branch: 'master'})), - {message: 'Unclean working tree. Commit or stash changes first.'}, - ); - - assertTaskFailed(t, 'Check local working tree'); -}); - -test.serial('should not fail when no remote set up', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - stderr: 'fatal: no upstream configured for branch \'master\'', - }, - ]); - - await t.notThrowsAsync( - run(gitTasks({branch: 'master'})), - ); -}); - -test.serial('should fail when remote history differs and changes are fetched', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - exitCode: 0, - }, - { - command: 'git fetch --dry-run', - exitCode: 0, - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - stdout: '1', // Has unpulled changes - }, - ]); - - await t.throwsAsync( - run(gitTasks({branch: 'master'})), - {message: 'Remote history differs. Please pull changes.'}, - ); - - assertTaskFailed(t, 'Check remote history'); -}); - -test.serial('should fail when remote has unfetched changes', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - exitCode: 0, - }, - { - command: 'git fetch --dry-run', - stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes - }, - ]); - - await t.throwsAsync( - run(gitTasks({branch: 'master'})), - {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, - ); - - assertTaskFailed(t, 'Check remote history'); -}); - -test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', async t => { - const gitTasks = await stubExeca([ - { - command: 'git symbolic-ref --short HEAD', - stdout: 'master', - }, - { - command: 'git status --porcelain', - stdout: '', - }, - { - command: 'git rev-parse @{u}', - exitCode: 0, - }, - { - command: 'git fetch --dry-run', - exitCode: 0, - }, - { - command: 'git rev-list --count --left-only @{u}...HEAD', - stdout: '0', - }, - ]); - - await t.notThrowsAsync( - run(gitTasks({branch: 'master'})), - ); -}); diff --git a/test/git-util/check-if-file-git-ignored.js b/test/git-util/check-if-file-git-ignored.js new file mode 100644 index 00000000..1c5ba52b --- /dev/null +++ b/test/git-util/check-if-file-git-ignored.js @@ -0,0 +1,20 @@ +import test from 'ava'; +import {temporaryDirectory} from 'tempy'; +import {checkIfFileGitIgnored} from '../../source/git-util.js'; + +test('returns true for ignored files', async t => { + t.true(await checkIfFileGitIgnored('yarn.lock')); +}); + +test('returns false for non-ignored files', async t => { + t.false(await checkIfFileGitIgnored('package.json')); +}); + +test('errors if path is outside of repo', async t => { + const temporary = temporaryDirectory(); + + await t.throwsAsync( + checkIfFileGitIgnored(`${temporary}/file.js`), + {message: /fatal:/}, + ); +}); diff --git a/test/git-util/commit-log-from-revision.js b/test/git-util/commit-log-from-revision.js new file mode 100644 index 00000000..bf0a780b --- /dev/null +++ b/test/git-util/commit-log-from-revision.js @@ -0,0 +1,32 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns single commit', createFixture, async () => { + // +}, async ({t, testedModule: {commitLogFromRevision}, $$}) => { + await $$`git tag v0.0.0`; + const {sha, commitMessage} = await t.context.commitNewFile(); + + t.is(await commitLogFromRevision('v0.0.0'), `${commitMessage} ${sha}`); +}); + +test('returns multiple commits, from newest to oldest', createFixture, async () => { + // +}, async ({t, testedModule: {commitLogFromRevision}, $$}) => { + await $$`git tag v0.0.0`; + const commit1 = await t.context.commitNewFile(); + const commit2 = await t.context.commitNewFile(); + const commit3 = await t.context.commitNewFile(); + + const commitLog = stripIndent` + ${commit3.commitMessage} ${commit3.sha} + ${commit2.commitMessage} ${commit2.sha} + ${commit1.commitMessage} ${commit1.sha} + `; + + t.is(await commitLogFromRevision('v0.0.0'), commitLog); +}); diff --git a/test/git-util/default-branch.js b/test/git-util/default-branch.js new file mode 100644 index 00000000..07fc1c6a --- /dev/null +++ b/test/git-util/default-branch.js @@ -0,0 +1,37 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('main', createFixture, async ({$$}) => { + await $$`git checkout -B main`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'main'); +}); + +test('master', createFixture, async ({$$}) => { + await $$`git checkout -B master`; + await $$`git update-ref -d refs/heads/main`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'master'); +}); + +test('gh-pages', createFixture, async ({$$}) => { + await $$`git checkout -B gh-pages`; + await $$`git update-ref -d refs/heads/main`; + await $$`git update-ref -d refs/heads/master`; +}, async ({t, testedModule: {defaultBranch}}) => { + t.is(await defaultBranch(), 'gh-pages'); +}); + +test('fails', createFixture, async ({$$}) => { + await $$`git checkout -B unicorn`; + await $$`git update-ref -d refs/heads/main`; + await $$`git update-ref -d refs/heads/master`; +}, async ({t, testedModule: {defaultBranch}}) => { + await t.throwsAsync( + defaultBranch(), + {message: 'Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'}, + ); +}); diff --git a/test/git-util/delete-tag.js b/test/git-util/delete-tag.js new file mode 100644 index 00000000..15c54016 --- /dev/null +++ b/test/git-util/delete-tag.js @@ -0,0 +1,40 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('deletes given tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {deleteTag}, $$}) => { + await deleteTag('v1.0.0'); + const {stdout: tags} = await $$`git tag`; + + t.is(tags, 'v0.0.0'); +}); + +test('deletes given tag from a large list', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; + await $$`git tag v1.0.0`; + await $$`git tag v2.0.0`; + await $$`git tag v3.0.0`; + await $$`git tag v4.0.0`; +}, async ({t, testedModule: {deleteTag}, $$}) => { + await deleteTag('v2.0.0'); + const {stdout: tags} = await $$`git tag`; + + t.deepEqual( + tags.split('\n'), + ['v0.0.0', 'v1.0.0', 'v3.0.0', 'v4.0.0'], + ); +}); + +test('throws if tag not found', createFixture, async () => { + // +}, async ({t, testedModule: {deleteTag}}) => { + await t.throwsAsync( + deleteTag('v1.0.0'), + {message: /error: tag 'v1\.0\.0' not found\./}, + ); +}); diff --git a/test/git-util/get-current-branch.js b/test/git-util/get-current-branch.js new file mode 100644 index 00000000..14623264 --- /dev/null +++ b/test/git-util/get-current-branch.js @@ -0,0 +1,12 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns current branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {getCurrentBranch}}) => { + const currentBranch = await getCurrentBranch(); + t.is(currentBranch, 'unicorn'); +}); diff --git a/test/git-util/has-upstream.js b/test/git-util/has-upstream.js new file mode 100644 index 00000000..43e67aec --- /dev/null +++ b/test/git-util/has-upstream.js @@ -0,0 +1,11 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('no upstream', createFixture, async () => { + // +}, async ({t, testedModule: {hasUpstream}}) => { + t.false(await hasUpstream()); +}); diff --git a/test/git-util/is-head-detached.js b/test/git-util/is-head-detached.js new file mode 100644 index 00000000..85de14a2 --- /dev/null +++ b/test/git-util/is-head-detached.js @@ -0,0 +1,18 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('not detached', createFixture, async () => { + // +}, async ({t, testedModule: {isHeadDetached}}) => { + t.false(await isHeadDetached()); +}); + +test('detached', createFixture, async ({$$}) => { + const {stdout: firstCommitSha} = await $$`git rev-list --max-parents=0 HEAD`; + await $$`git checkout ${firstCommitSha}`; +}, async ({t, testedModule: {isHeadDetached}}) => { + t.true(await isHeadDetached()); +}); diff --git a/test/git-util/latest-tag-or-first-commit.js b/test/git-util/latest-tag-or-first-commit.js new file mode 100644 index 00000000..526938c4 --- /dev/null +++ b/test/git-util/latest-tag-or-first-commit.js @@ -0,0 +1,33 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +// From https://stackoverflow.com/a/3357357/10292952 +const getCommitMessage = async ($$, sha) => $$`git log --format=%B -n 1 ${sha}`; + +test('one tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {latestTagOrFirstCommit}}) => { + const result = await latestTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +test('two tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.commitNewFile(); + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {latestTagOrFirstCommit}}) => { + const result = await latestTagOrFirstCommit(); + t.is(result, 'v1.0.0'); +}); + +test('no tags (fallback)', createFixture, async () => { + // +}, async ({t, testedModule: {latestTagOrFirstCommit}, $$}) => { + const result = await latestTagOrFirstCommit(); + const {stdout: firstCommitMessage} = await getCommitMessage($$, result); + + t.is(firstCommitMessage.trim(), '"init1"'); +}); diff --git a/test/git-util/latest-tag.js b/test/git-util/latest-tag.js new file mode 100644 index 00000000..9c876f85 --- /dev/null +++ b/test/git-util/latest-tag.js @@ -0,0 +1,23 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns latest tag', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {latestTag}}) => { + t.is(await latestTag(), 'v0.0.0'); +}); + +test('returns latest tag - multiple set', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + /* eslint-disable no-await-in-loop */ + for (const major of [1, 2, 3, 4]) { + await t.context.commitNewFile(); + await $$`git tag v${major}.0.0`; + } + /* eslint-enable no-await-in-loop */ +}, async ({t, testedModule: {latestTag}}) => { + t.is(await latestTag(), 'v4.0.0'); +}); diff --git a/test/git-util/new-files-since-last-release.js b/test/git-util/new-files-since-last-release.js new file mode 100644 index 00000000..01ceb737 --- /dev/null +++ b/test/git-util/new-files-since-last-release.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns files added since latest tag', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDir); + t.deepEqual( + newFiles.sort(), + ['new', 'index.js'].sort(), + ); +}); + +test('no files', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDir); + t.deepEqual(newFiles, []); +}); + +test('uses ignoreWalker', createFixture, async ({t}) => { + await t.context.createFile('index.js'); + await t.context.createFile('package.json'); + await t.context.createFile('package-lock.json'); + await t.context.createFile('.gitignore', 'package-lock.json\n.git'); // ignoreWalker doesn't ignore `.git`: npm/ignore-walk#2 +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDir); + t.deepEqual( + newFiles.sort(), + ['index.js', 'package.json', '.gitignore'].sort(), + ); +}); diff --git a/test/git-util/previous-tag-or-first-commit.js b/test/git-util/previous-tag-or-first-commit.js new file mode 100644 index 00000000..53287bbd --- /dev/null +++ b/test/git-util/previous-tag-or-first-commit.js @@ -0,0 +1,45 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('no tags', createFixture, () => { + // +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, undefined); +}); + +test('one tag - fallback to first commit', createFixture, async ({$$}) => { + await $$`git tag v0.0.0`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + const commitMessage = await t.context.getCommitMessage(result); + + t.is(commitMessage, t.context.firstCommitMessage); +}); + +test('two tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.commitNewFile(); + await $$`git tag v1.0.0`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, 'v0.0.0'); +}); + +test('multiple tags', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + /* eslint-disable no-await-in-loop */ + for (const major of [1, 2, 3, 4]) { + await t.context.commitNewFile(); + await $$`git tag v${major}.0.0`; + } + /* eslint-enable no-await-in-loop */ +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + const result = await previousTagOrFirstCommit(); + t.is(result, 'v3.0.0'); +}); + +test.todo('test fallback case'); diff --git a/test/git-util/push-graceful.js b/test/git-util/push-graceful.js new file mode 100644 index 00000000..97b6c14a --- /dev/null +++ b/test/git-util/push-graceful.js @@ -0,0 +1,40 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js', import.meta.url); + +test('succeeds', createFixture, [{ + command: 'git push --follow-tags', + exitCode: 0, +}], async ({t, testedModule: {pushGraceful}}) => { + await t.notThrowsAsync( + pushGraceful(), + ); +}); + +test('fails w/ remote on GitHub and bad branch permission', createFixture, [ + { + command: 'git push --follow-tags', + stderr: 'GH006', + }, + { + command: 'git push --tags', + exitCode: 0, + }, +], async ({t, testedModule: {pushGraceful}}) => { + const {pushed, reason} = await pushGraceful(true); + + t.is(pushed, 'tags'); + t.is(reason, 'Branch protection: np can`t push the commits. Push them manually.'); +}); + +test('throws', createFixture, [{ + command: 'git push --follow-tags', + exitCode: 1, +}], async ({t, testedModule: {pushGraceful}}) => { + await t.throwsAsync( + pushGraceful(false), + ); +}); + diff --git a/test/git-util/read-file-from-last-release.js b/test/git-util/read-file-from-last-release.js new file mode 100644 index 00000000..7d945f63 --- /dev/null +++ b/test/git-util/read-file-from-last-release.js @@ -0,0 +1,43 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns content of a given file', createFixture, async ({t, $$}) => { + await t.context.createFile('unicorn.txt', 'unicorn-1'); + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + await t.context.createFile('unicorn.txt', 'unicorn-2'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + const file = await readFileFromLastRelease('unicorn.txt'); + t.is(file, 'unicorn-1'); +}); + +test('fails if file not in previous release', createFixture, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('unicorn.txt', 'unicorn'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + await t.throwsAsync( + readFileFromLastRelease('unicorn.txt'), + {message: /fatal: path '[^']*' exists on disk, but not in 'v0\.0\.0'/}, + ); +}); + +test('no previous release', createFixture, async ({t, $$}) => { + await t.context.createFile('unicorn.txt', 'unicorn'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {readFileFromLastRelease}}) => { + await t.throwsAsync( + readFileFromLastRelease('unicorn.txt'), + {message: /fatal: No names found, cannot describe anything./}, + ); +}); + +// These errors could probably be handled in 'readFileFromLastRelease' diff --git a/test/git-util/remove-last-commit.js b/test/git-util/remove-last-commit.js new file mode 100644 index 00000000..de7d794f --- /dev/null +++ b/test/git-util/remove-last-commit.js @@ -0,0 +1,19 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('removes latest commit', createFixture, async ({t, $$}) => { + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {removeLastCommit}, $$}) => { + const {stdout: commitsBefore} = await $$`git log --pretty="%s"`; + t.true(commitsBefore.includes('"added"')); + + await removeLastCommit(); + + const {stdout: commitsAfter} = await $$`git log --pretty="%s"`; + t.false(commitsAfter.includes('"added"')); +}); diff --git a/test/git-util/root.js b/test/git-util/root.js new file mode 100644 index 00000000..6689df70 --- /dev/null +++ b/test/git-util/root.js @@ -0,0 +1,17 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; +import {npRootDir} from '../../source/util.js'; +import {root} from '../../source/git-util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('returns np root dir', async t => { + t.is(await root(), npRootDir); +}); + +test('returns root dir of temp dir', createFixture, () => { + // +}, async ({t, testedModule: git, temporaryDir}) => { + t.is(await git.root(), temporaryDir); +}); diff --git a/test/git-util/verify-current-branch-is-release-branch.js b/test/git-util/verify-current-branch-is-release-branch.js new file mode 100644 index 00000000..fa6165b6 --- /dev/null +++ b/test/git-util/verify-current-branch-is-release-branch.js @@ -0,0 +1,22 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('on release branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {verifyCurrentBranchIsReleaseBranch}}) => { + await t.notThrowsAsync( + verifyCurrentBranchIsReleaseBranch('unicorn'), + ); +}); + +test('not on release branch', createFixture, async ({$$}) => { + await $$`git switch -c unicorn`; +}, async ({t, testedModule: {verifyCurrentBranchIsReleaseBranch}}) => { + await t.throwsAsync( + verifyCurrentBranchIsReleaseBranch('main'), + {message: 'Not on `main` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); +}); diff --git a/test/git-util/verify-recent-git-version.js b/test/git-util/verify-recent-git-version.js new file mode 100644 index 00000000..2a45601e --- /dev/null +++ b/test/git-util/verify-recent-git-version.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js', import.meta.url); + +test('satisfied', createFixture, [{ + command: 'git version', + stdout: 'git version 2.12.0', // One higher than minimum +}], async ({t, testedModule: {verifyRecentGitVersion}}) => { + await t.notThrowsAsync( + verifyRecentGitVersion(), + ); +}); + +test('not satisfied', createFixture, [{ + command: 'git version', + stdout: 'git version 2.10.0', // One lower than minimum +}], async ({t, testedModule: {verifyRecentGitVersion}}) => { + await t.throwsAsync( + verifyRecentGitVersion(), + {message: '`np` requires git >=2.11.0'}, + ); +}); diff --git a/test/git-util/verify-remote-history-is-clean.js b/test/git-util/verify-remote-history-is-clean.js new file mode 100644 index 00000000..14fd8ef1 --- /dev/null +++ b/test/git-util/verify-remote-history-is-clean.js @@ -0,0 +1,74 @@ +import test from 'ava'; +import {_createFixture as _createStubFixture} from '../_helpers/stub-execa.js'; +import {_createFixture as _createIntegrationFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createStubFixture = _createStubFixture('../../source/git-util.js', import.meta.url); + +/** @type {ReturnType>} */ +const createIntegrationFixture = _createIntegrationFixture('../../source/git-util.js'); + +test('unfetched changes', createStubFixture, [ + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes + }, +], async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + await t.throwsAsync( + verifyRemoteHistoryIsClean(), + {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, + ); +}); + +test('unclean remote history', createStubFixture, [ + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '1', // Has unpulled changes + }, +], async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + await t.throwsAsync( + verifyRemoteHistoryIsClean(), + {message: 'Remote history differs. Please pull changes.'}, + ); +}); + +test('clean fetched remote history', createStubFixture, [ + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', // No changes + }, +], async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + await t.notThrowsAsync( + verifyRemoteHistoryIsClean(), + ); +}); + +test('no remote', createIntegrationFixture, async () => { + // +}, async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { + const result = await t.notThrowsAsync( + verifyRemoteHistoryIsClean(), + ); + + t.is(result, undefined); +}); diff --git a/test/git-util/verify-remote-is-valid.js b/test/git-util/verify-remote-is-valid.js new file mode 100644 index 00000000..fce75912 --- /dev/null +++ b/test/git-util/verify-remote-is-valid.js @@ -0,0 +1,27 @@ +import test from 'ava'; +import {_createFixture as _createStubFixture} from '../_helpers/stub-execa.js'; +import {_createFixture as _createIntegrationFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createStubFixture = _createStubFixture('../../source/git-util.js', import.meta.url); + +/** @type {ReturnType>} */ +const createIntegrationFixture = _createIntegrationFixture('../../source/git-util.js'); + +test('has remote', createStubFixture, [{ + command: 'git ls-remote origin HEAD', + exitCode: 0, +}], async ({t, testedModule: {verifyRemoteIsValid}}) => { + await t.notThrowsAsync( + verifyRemoteIsValid(), + ); +}); + +test('no remote', createIntegrationFixture, async () => { + // +}, async ({t, testedModule: {verifyRemoteIsValid}}) => { + await t.throwsAsync( + verifyRemoteIsValid(), + {message: /^Git fatal error:/m}, + ); +}); diff --git a/test/git-util/verify-tag-does-not-exist-on-remote.js b/test/git-util/verify-tag-does-not-exist-on-remote.js new file mode 100644 index 00000000..216c9d02 --- /dev/null +++ b/test/git-util/verify-tag-does-not-exist-on-remote.js @@ -0,0 +1,26 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js', import.meta.url); + +test('exists', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', + stdout: '123456789', // Some hash +}], async ({t, testedModule: {verifyTagDoesNotExistOnRemote}}) => { + await t.throwsAsync( + verifyTagDoesNotExistOnRemote('v0.0.0'), + {message: 'Git tag `v0.0.0` already exists.'}, + ); +}); + +test('does not exist', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v0.0.0', + exitCode: 1, + stderr: '', + stdout: '', +}], async ({t, testedModule: {verifyTagDoesNotExistOnRemote}}) => { + await t.notThrowsAsync( + verifyTagDoesNotExistOnRemote('v0.0.0'), + ); +}); diff --git a/test/git-util/verify-working-tree-is-clean.js b/test/git-util/verify-working-tree-is-clean.js new file mode 100644 index 00000000..d1fc0774 --- /dev/null +++ b/test/git-util/verify-working-tree-is-clean.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('clean', createFixture, async ({t, $$}) => { + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { + await t.notThrowsAsync( + verifyWorkingTreeIsClean(), + ); +}); + +test('not clean', createFixture, async ({t}) => { + await t.context.createFile('index.js'); +}, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { + await t.throwsAsync( + verifyWorkingTreeIsClean(), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); +}); diff --git a/test/hyperlinks.js b/test/hyperlinks.js deleted file mode 100644 index c5879174..00000000 --- a/test/hyperlinks.js +++ /dev/null @@ -1,56 +0,0 @@ -import test from 'ava'; -import sinon from 'sinon'; -import terminalLink from 'terminal-link'; -import {linkifyIssues, linkifyCommit, linkifyCommitRange} from '../source/util.js'; - -const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; -const MOCK_COMMIT_HASH = '5063f8a'; -const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; - -const sandbox = sinon.createSandbox(); - -test.afterEach(() => { - sandbox.restore(); -}); - -const mockTerminalLinkUnsupported = () => - sandbox.stub(terminalLink, 'isSupported').value(false); - -test('linkifyIssues correctly links issues', t => { - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #3 #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/3#3]8;; ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); - t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes foo/bar#4'), 'Commit message - fixes ]8;;https://github.com/foo/bar/issues/4foo/bar#4]8;;'); -}); - -test('linkifyIssues returns raw message if url is not provided', t => { - const message = 'Commit message - fixes #5'; - t.is(linkifyIssues(undefined, message), message); -}); - -test.serial('linkifyIssues returns raw message if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - const message = 'Commit message - fixes #6'; - t.is(linkifyIssues(MOCK_REPO_URL, message), message); -}); - -test('linkifyCommit correctly links commits', t => { - t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), ']8;;https://github.com/unicorn/rainbow/commit/5063f8a5063f8a]8;;'); -}); - -test('linkifyCommit returns raw commit hash if url is not provided', t => { - t.is(linkifyCommit(undefined, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); -}); - -test.serial('linkifyCommit returns raw commit hash if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); -}); - -test('linkifyCommitRange returns raw commitRange if url is not provided', t => { - t.is(linkifyCommitRange(undefined, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); -}); - -test.serial('linkifyCommitRange returns raw commitRange if terminalLink is not supported', t => { - mockTerminalLinkUnsupported(); - t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); -}); diff --git a/test/index.js b/test/index.js index 03a0660c..bf3ac759 100644 --- a/test/index.js +++ b/test/index.js @@ -16,18 +16,18 @@ const defaultOptions = { renderer: 'silent', }; -const npPkg = await util.readPkg(); +const npPkgResult = await util.readPkg(); const npFails = test.macro(async (t, inputs, message) => { await t.throwsAsync( - Promise.all(inputs.map(input => np(input, defaultOptions, npPkg))), + Promise.all(inputs.map(input => np(input, defaultOptions, npPkgResult))), {message}, ); }); test('version is invalid', npFails, ['foo', '4.x.3'], - 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.', + /New version (?:foo|4\.x\.3) should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version\./, ); test('version is pre-release', npFails, @@ -37,12 +37,13 @@ test('version is pre-release', npFails, test('errors on too low version', npFails, ['1.0.0', '1.0.0-beta'], - /New version `1\.0\.0(?:-beta)?` should be higher than current version `\d+\.\d+\.\d+`/, + /New version 1\.0\.0(?:-beta)? should be higher than current version \d+\.\d+\.\d+/, ); test('skip enabling 2FA if the package exists', async t => { const enable2faStub = sinon.stub(); + /** @type {typeof np} */ const npMock = await esmock('../source/index.js', { del: {deleteAsync: sinon.stub()}, execa: {execa: sinon.stub().returns({pipe: sinon.stub()})}, @@ -62,7 +63,7 @@ test('skip enabling 2FA if the package exists', async t => { isAvailable: false, isUnknown: false, }, - }, npPkg)); + }, npPkgResult)); t.true(enable2faStub.notCalled); }); @@ -70,6 +71,7 @@ test('skip enabling 2FA if the package exists', async t => { test('skip enabling 2FA if the `2fa` option is false', async t => { const enable2faStub = sinon.stub(); + /** @type {typeof np} */ const npMock = await esmock('../source/index.js', { del: {deleteAsync: sinon.stub()}, execa: {execa: sinon.stub().returns({pipe: sinon.stub()})}, @@ -90,7 +92,7 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { isUnknown: false, }, '2fa': false, - }, npPkg)); + }, npPkgResult)); t.true(enable2faStub.notCalled); }); diff --git a/test/integration.js b/test/integration.js deleted file mode 100644 index fab34722..00000000 --- a/test/integration.js +++ /dev/null @@ -1,105 +0,0 @@ -import path from 'node:path'; -import fs from 'fs-extra'; -import test from 'ava'; -import esmock from 'esmock'; -import {$, execa} from 'execa'; -import {temporaryDirectoryTask} from 'tempy'; -import {writePackage} from 'write-pkg'; - -const createEmptyGitRepo = async ($$, temporaryDir) => { - await $$`git init`; - - // `git tag` needs an initial commit - await fs.createFile(path.resolve(temporaryDir, 'temp')); - await $$`git add temp`; - await $$`git commit -m "init1"`; - await $$`git rm temp`; - await $$`git commit -m "init2"`; -}; - -const createIntegrationTest = async (t, assertions) => { - await temporaryDirectoryTask(async temporaryDir => { - const $$ = $({cwd: temporaryDir}); - - await createEmptyGitRepo($$, temporaryDir); - - t.context.createFile = async file => fs.createFile(path.resolve(temporaryDir, file)); - await assertions($$, temporaryDir); - }); -}; - -test('main', async t => { - await createIntegrationTest(t, async $$ => { - await t.context.createFile('testFile'); - - const {stdout} = await $$`git status -u`; - - t.true( - stdout.includes('Untracked files') && stdout.includes('testFile'), - 'File wasn\'t created properly!', - ); - }); -}); - -const createNewFilesFixture = test.macro(async (t, pkgFiles, commands, {unpublished, firstTime}) => { - await createIntegrationTest(t, async ($$, temporaryDir) => { - /** @type {import('../source/util.js')} */ - const util = await esmock('../source/util.js', {}, { - 'node:process': {cwd: () => temporaryDir}, - execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, - }); - - await commands(t, $$, temporaryDir); - - await writePackage(temporaryDir, { - name: 'foo', - version: '0.0.0', - ...pkgFiles.length > 0 ? {files: pkgFiles} : {}, - }); - - t.deepEqual( - await util.getNewFiles(temporaryDir), - {unpublished, firstTime}, - ); - }); -}); - -test('files to package with tags added', createNewFilesFixture, ['*.js'], async (t, $$) => { - await $$`git tag v0.0.0`; - await t.context.createFile('new'); - await t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, {unpublished: ['new'], firstTime: ['index.js']}); - -test('file `new` to package without tags added', createNewFilesFixture, ['index.js'], async t => { - await t.context.createFile('new'); - await t.context.createFile('index.js'); -}, {unpublished: ['new'], firstTime: ['index.js', 'package.json']}); - -(() => { // Wrapper to have constants with macro - const longPath = path.join('veryLonggggggDirectoryName', 'veryLonggggggDirectoryName'); - const filePath1 = path.join(longPath, 'file1'); - const filePath2 = path.join(longPath, 'file2'); - - test('files with long pathnames added', createNewFilesFixture, ['*.js'], async (t, $$) => { - await $$`git tag v0.0.0`; - await t.context.createFile(filePath1); - await t.context.createFile(filePath2); - await $$`git add -A`; - await $$`git commit -m "added"`; - }, {unpublished: [filePath1, filePath2], firstTime: []}); -})(); - -test('no new files added', createNewFilesFixture, [], async (_t, $$) => { - await $$`git tag v0.0.0`; -}, {unpublished: [], firstTime: []}); - -test('ignores .git and .github files', createNewFilesFixture, ['*.js'], async (t, $$) => { - await $$`git tag v0.0.0`; - await t.context.createFile('.github/workflows/main.yml'); - await t.context.createFile('.github/pull_request_template'); - await t.context.createFile('index.js'); - await $$`git add -A`; - await $$`git commit -m "added"`; -}, {unpublished: [], firstTime: ['index.js']}); diff --git a/test/npm/enable-2fa.js b/test/npm/enable-2fa.js new file mode 100644 index 00000000..59fa5203 --- /dev/null +++ b/test/npm/enable-2fa.js @@ -0,0 +1,45 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/npm/enable-2fa.js', import.meta.url); + +const npmVersionFixtures = [ + {version: '8.0.0', accessArgs: ['access', '2fa-required']}, + {version: '9.0.0', accessArgs: ['access', 'set', 'mfa=publish']}, +]; + +for (const {version, accessArgs} of npmVersionFixtures) { + const npmVersionCommand = [{ + command: 'npm --version', + stdout: version, + }]; + + test(`npm v${version} - no options`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np'), + [...accessArgs, 'np'], + ); + }, + ); + + test(`npm v${version} - options, no otp`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np', {confirm: true}), + [...accessArgs, 'np'], + ); + }, + ); + + test(`npm v${version} - options, with otp`, createFixture, npmVersionCommand, + async ({t, testedModule: {getEnable2faArgs}}) => { + t.deepEqual( + await getEnable2faArgs('np', {otp: '123456'}), + [...accessArgs, 'np', '--otp', '123456'], + ); + }, + ); +} + diff --git a/test/npm/handle-npm-error.js b/test/npm/handle-npm-error.js new file mode 100644 index 00000000..61b6959a --- /dev/null +++ b/test/npm/handle-npm-error.js @@ -0,0 +1,20 @@ +import test from 'ava'; +import handleNpmError from '../../source/npm/handle-npm-error.js'; + +const makeError = ({code, stdout, stderr}) => ({ + code, + stdout: stdout ?? '', + stderr: stderr ?? '', +}); + +test('error code 402 - privately publish scoped package', t => { + t.throws( + () => handleNpmError(makeError({code: 402})), + {message: 'You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'}, + ); + + t.throws( + () => handleNpmError(makeError({stderr: 'npm ERR! 402 Payment Required'})), + {message: 'You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'}, + ); +}); diff --git a/test/npm/publish.js b/test/npm/publish.js new file mode 100644 index 00000000..316747fa --- /dev/null +++ b/test/npm/publish.js @@ -0,0 +1,37 @@ +import test from 'ava'; +import {getPackagePublishArguments} from '../../source/npm/publish.js'; + +test('no options set', t => { + t.deepEqual( + getPackagePublishArguments({}), + ['publish'], + ); +}); + +test('options.contents', t => { + t.deepEqual( + getPackagePublishArguments({contents: 'dist'}), + ['publish', 'dist'], + ); +}); + +test('options.tag', t => { + t.deepEqual( + getPackagePublishArguments({tag: 'beta'}), + ['publish', '--tag', 'beta'], + ); +}); + +test('options.otp', t => { + t.deepEqual( + getPackagePublishArguments({otp: '123456'}), + ['publish', '--otp', '123456'], + ); +}); + +test('options.publishScoped', t => { + t.deepEqual( + getPackagePublishArguments({publishScoped: true}), + ['publish', '--access', 'public'], + ); +}); diff --git a/test/npm/util/check-connection.js b/test/npm/util/check-connection.js new file mode 100644 index 00000000..39e32362 --- /dev/null +++ b/test/npm/util/check-connection.js @@ -0,0 +1,36 @@ +import {setTimeout} from 'node:timers/promises'; +import test from 'ava'; +import esmock from 'esmock'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('success', createFixture, [{ + command: 'npm ping', + exitCode: 0, +}], async ({t, testedModule: npm}) => { + t.true(await npm.checkConnection()); +}); + +test('fail', createFixture, [{ + command: 'npm ping', + exitCode: 1, +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.checkConnection(), + {message: 'Connection to npm registry failed'}, + ); +}); + +test('timeout', async t => { + t.timeout(16_000); + const npm = await esmock('../../../source/npm/util.js', {}, { + execa: {execa: async () => setTimeout(16_000, {})}, + }); + + await t.throwsAsync( + npm.checkConnection(), + {message: 'Connection to npm registry timed out'}, + ); +}); diff --git a/test/npm/util/check-ignore-strategy.js b/test/npm/util/check-ignore-strategy.js new file mode 100644 index 00000000..1617eb85 --- /dev/null +++ b/test/npm/util/check-ignore-strategy.js @@ -0,0 +1,35 @@ +import path from 'node:path'; +import test from 'ava'; +import esmock from 'esmock'; +import stripAnsi from 'strip-ansi'; +import {oneLine} from 'common-tags'; + +const checkIgnoreStrategy = test.macro(async (t, {fixture = '', files, expected = ''} = {}) => { + let output = ''; + + /** @type {import('../../../source/npm/util.js')} */ + const {checkIgnoreStrategy} = await esmock('../../../source/npm/util.js', { + import: {console: {log: (...args) => output = args.join('')}}, // eslint-disable-line no-return-assign + }); + + const fixtureDir = path.resolve('test/fixtures/files', fixture); + const pkg = files ? {files} : {}; + + await checkIgnoreStrategy(pkg, fixtureDir); + + output = stripAnsi(output).trim(); + t.is(output, expected); +}); + +const ignoreStrategyMessage = oneLine` + Warning: No files field specified in package.json nor is a .npmignore file present. + Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. +`; + +test('no files, no .npmignore', checkIgnoreStrategy, {fixture: 'main', expected: ignoreStrategyMessage}); + +test('no files w/ .npmignore', checkIgnoreStrategy, {fixture: 'npmignore', expected: ''}); + +test('files, no .npmignore', checkIgnoreStrategy, {fixture: 'main', files: ['index.js'], expected: ''}); + +test('files w/ .npmignore', checkIgnoreStrategy, {fixture: 'npmignore', files: ['index.js'], expected: ''}); diff --git a/test/npm/util/collaborators.js b/test/npm/util/collaborators.js new file mode 100644 index 00000000..93fb5c06 --- /dev/null +++ b/test/npm/util/collaborators.js @@ -0,0 +1,93 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../../_helpers/stub-execa.js'; +import * as npm from '../../../source/npm/util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('pkg.name not a string', async t => { + await t.throwsAsync( + npm.collaborators({name: 1}), + {message: 'Expected argument to be of type `string` but received type `number`'}, + ); +}); + +const npmVersionFixtures = [ + {version: '8.0.0', accessCommand: 'npm access ls-collaborators np'}, + {version: '9.0.0', accessCommand: 'npm access list collaborators np --json'}, +]; + +for (const {version, accessCommand} of npmVersionFixtures) { + const npmVersionCommand = { + command: 'npm --version', + stdout: version, + }; + + const collaboratorsStdout = stripIndent` + { + "sindresorhus": "read-write", + "samverschueren": "read-write", + "itaisteinherz": "read-write" + } + `; + + test(`npm v${version}`, createFixture, [ + npmVersionCommand, + { + command: accessCommand, + stdout: collaboratorsStdout, + }, + ], async ({t, testedModule: {collaborators}}) => { + t.deepEqual( + await collaborators({name: 'np'}), + collaboratorsStdout, + ); + }); + + test(`npm v${version} - external registry`, createFixture, [ + npmVersionCommand, + { + command: `${accessCommand} --registry http://my-internal-registry.local`, + stdout: collaboratorsStdout, + }, + ], async ({t, testedModule: {collaborators}}) => { + t.deepEqual( + await collaborators({ + name: 'np', + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }), + collaboratorsStdout, + ); + }); + + test(`npm v${version} - non-existent`, createFixture, [ + npmVersionCommand, + { + command: version === '8.0.0' + ? 'npm access ls-collaborators non-existent' + : 'npm access list collaborators non-existent --json', + stderr: 'npm ERR! code E404\nnpm ERR! 404 Not Found', + }, + ], async ({t, testedModule: {collaborators}}) => { + t.is( + await collaborators({name: 'non-existent'}), + false, + ); + }); + + test(`npm v${version} - error`, createFixture, [ + npmVersionCommand, + { + command: version === '8.0.0' + ? 'npm access ls-collaborators @private/pkg' + : 'npm access list collaborators @private/pkg --json', + stderr: 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden', + }, + ], async ({t, testedModule: {collaborators}}) => { + const {stderr} = await t.throwsAsync(collaborators({name: '@private/pkg'})); + t.is(stderr, 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden'); + }); +} diff --git a/test/npm/util/get-registry-url.js b/test/npm/util/get-registry-url.js new file mode 100644 index 00000000..fdfd47ea --- /dev/null +++ b/test/npm/util/get-registry-url.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('npm', createFixture, [{ + command: 'npm config get registry', + stdout: 'https://registry.npmjs.org/', +}], async ({t, testedModule: npm}) => { + t.is( + await npm.getRegistryUrl('npm', {}), + 'https://registry.npmjs.org/', + ); +}); + +test('yarn', createFixture, [{ + command: 'yarn config get registry', + stdout: 'https://registry.yarnpkg.com', +}], async ({t, testedModule: npm}) => { + t.is( + await npm.getRegistryUrl('yarn', {}), + 'https://registry.yarnpkg.com', + ); +}); + +test('external', createFixture, [{ + command: 'npm config get registry --registry http://my-internal-registry.local', + stdout: 'http://my-internal-registry.local', +}], async ({t, testedModule: npm}) => { + t.is( + await npm.getRegistryUrl('npm', { + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }), + 'http://my-internal-registry.local', + ); +}); diff --git a/test/npm/util/is-external-registry.js b/test/npm/util/is-external-registry.js new file mode 100644 index 00000000..c449cc66 --- /dev/null +++ b/test/npm/util/is-external-registry.js @@ -0,0 +1,10 @@ +import test from 'ava'; +import * as npm from '../../../source/npm/util.js'; + +test('main', t => { + t.true(npm.isExternalRegistry({publishConfig: {registry: 'https://my-internal-registry.local'}})); + + t.false(npm.isExternalRegistry({name: 'foo'})); + t.false(npm.isExternalRegistry({publishConfig: {registry: true}})); + t.false(npm.isExternalRegistry({publishConfig: 'not an object'})); +}); diff --git a/test/npm/util/is-package-name-available.js b/test/npm/util/is-package-name-available.js new file mode 100644 index 00000000..084f45d6 --- /dev/null +++ b/test/npm/util/is-package-name-available.js @@ -0,0 +1,42 @@ +import test from 'ava'; +import esmock from 'esmock'; +import sinon from 'sinon'; + +const externalRegistry = 'http://my-internal-registry.local'; + +const createFixture = test.macro(async (t, {name = 'foo', npmNameStub, expected, isExternalRegistry = false}) => { + /** @type {import('../../../source/npm/util.js')} */ + const npm = await esmock('../../../source/npm/util.js', { + 'npm-name': npmNameStub, + }); + + const pkg = isExternalRegistry + ? {name, publishConfig: {registry: externalRegistry}} + : {name}; + + const availability = await npm.isPackageNameAvailable(pkg); + t.like(availability, expected); +}); + +test('available', createFixture, { + npmNameStub: sinon.stub().resolves(true), + expected: {isAvailable: true, isUnknown: false}, +}); + +test('unavailable', createFixture, { + npmNameStub: sinon.stub().resolves(false), + expected: {isAvailable: false, isUnknown: false}, +}); + +test('bad package name', createFixture, { + name: '_foo', + npmNameStub: sinon.stub().rejects('Invalid package name: _foo\n- name cannot start with an underscore'), + expected: {isAvailable: false, isUnknown: true}, +}); + +test('external registry', createFixture, { + name: 'external-foo', + isExternalRegistry: true, + npmNameStub: async (name, {registryUrl}) => name === 'external-foo' && registryUrl === externalRegistry, + expected: {isAvailable: true, isUnknown: false}, +}); diff --git a/test/packed-files.js b/test/npm/util/packed-files.js similarity index 81% rename from test/packed-files.js rename to test/npm/util/packed-files.js index b2a44aad..a3034fb9 100644 --- a/test/packed-files.js +++ b/test/npm/util/packed-files.js @@ -1,8 +1,7 @@ import path from 'node:path'; import test from 'ava'; -import {renameFile} from 'move-file'; -import {getFilesToBePacked} from '../source/npm/util.js'; -import {runIfExists} from './_utils.js'; +import {getFilesToBePacked} from '../../../source/npm/util.js'; +import {runIfExists} from '../../_helpers/util.js'; const getFixture = name => path.resolve('test', 'fixtures', 'files', name); @@ -53,24 +52,15 @@ test('package.json files field and npmignore', verifyPackedFiles, 'files-and-npm 'source/index.d.ts', ]); -const renameDotGitignore = { - async before(fixtureDir) { - await renameFile('gitignore', '.gitignore', {cwd: fixtureDir}); - }, - async after(fixtureDir) { - await renameFile('.gitignore', 'gitignore', {cwd: fixtureDir}); - }, -}; - test('package.json files field and gitignore', verifyPackedFiles, 'gitignore', [ 'readme.md', 'dist/index.js', -], renameDotGitignore); +]); test('npmignore and gitignore', verifyPackedFiles, 'npmignore-and-gitignore', [ 'readme.md', 'dist/index.js', -], renameDotGitignore); +]); test('package.json main field not in files field', verifyPackedFiles, 'main', [ 'foo.js', diff --git a/test/npm/util/prerelease-tags.js b/test/npm/util/prerelease-tags.js new file mode 100644 index 00000000..a620cfd9 --- /dev/null +++ b/test/npm/util/prerelease-tags.js @@ -0,0 +1,89 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../../_helpers/stub-execa.js'; +import * as npm from '../../../source/npm/util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('packageName not a string', async t => { + await t.throwsAsync( + npm.prereleaseTags(1), + {message: 'Expected argument to be of type `string` but received type `number`'}, + ); +}); + +test('tags: latest', createFixture, [{ + command: 'npm view --json foo dist-tags', + stdout: JSON.stringify({ + latest: '1.0.0', + }), +}], async ({t, testedModule: {prereleaseTags}}) => { + t.deepEqual( + await prereleaseTags('foo'), + ['next'], + ); +}); + +test('tags: latest, beta', createFixture, [{ + command: 'npm view --json foo dist-tags', + stdout: JSON.stringify({ + latest: '1.0.0', + beta: '2.0.0-beta', + }), +}], async ({t, testedModule: {prereleaseTags}}) => { + t.deepEqual( + await prereleaseTags('foo'), + ['beta'], + ); +}); + +test('non-existent (code 404) - should not throw', createFixture, [{ + command: 'npm view --json non-existent dist-tags', + stderr: stripIndent` + npm ERR! code E404 + npm ERR! 404 Not Found - GET https://registry.npmjs.org/non-existent - Not found + npm ERR! 404 + npm ERR! 404 'non-existent@*' is not in this registry. + npm ERR! 404 + npm ERR! 404 Note that you can also install from a + npm ERR! 404 tarball, folder, http url, or git url. + { + "error": { + "code": "E404", + "summary": "Not Found - GET https://registry.npmjs.org/non-existent - Not found", + "detail": "'non-existent@*' is not in this registry. Note that you can also install from a tarball, folder, http url, or git url." + } + } + npm ERR! A complete log of this run can be found in: + npm ERR! ~/.npm/_logs/...-debug.log + `, +}], async ({t, testedModule: {prereleaseTags}}) => { + t.deepEqual( + await prereleaseTags('non-existent'), + ['next'], + ); +}); + +test('bad permission (code 403) - should throw', createFixture, [{ + command: 'npm view --json @private/pkg dist-tags', + stderr: stripIndent` + npm ERR! code E403 + npm ERR! 403 403 Forbidden - GET https://registry.npmjs.org/@private%2fpkg - Forbidden + npm ERR! 403 In most cases, you or one of your dependencies are requesting + npm ERR! 403 a package version that is forbidden by your security policy, or + npm ERR! 403 on a server you do not have access to. + { + "error": { + "code": "E403", + "summary": "403 Forbidden - GET https://registry.npmjs.org/@private%2fpkg - Forbidden", + "detail": "In most cases, you or one of your dependencies are requesting a package version that is forbidden by your security policy, or on a server you do not have access to." + } + } + npm ERR! A complete log of this run can be found in: + npm ERR! ~/.npm/_logs/...-debug.log + `, +}], async ({t, testedModule: {prereleaseTags}}) => { + const error = await t.throwsAsync(prereleaseTags('@private/pkg')); + t.true(error.stderr?.includes('E403')); +}); diff --git a/test/npm/util/username.js b/test/npm/util/username.js new file mode 100644 index 00000000..87235890 --- /dev/null +++ b/test/npm/util/username.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('main', createFixture, [{ + command: 'npm whoami', + stdout: 'sindresorhus', +}], async ({t, testedModule: npm}) => { + t.is(await npm.username({}), 'sindresorhus'); +}); + +test('--registry flag', createFixture, [{ + command: 'npm whoami --registry http://my.io', + stdout: 'sindresorhus', +}], async ({t, testedModule: npm}) => { + t.is(await npm.username({externalRegistry: 'http://my.io'}), 'sindresorhus'); +}); + +test('fails if not logged in', createFixture, [{ + command: 'npm whoami', + stderr: 'npm ERR! code ENEEDAUTH', +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.username({}), + {message: 'You must be logged in. Use `npm login` and try again.'}, + ); +}); + +test('fails with authentication error', createFixture, [{ + command: 'npm whoami', + stderr: 'npm ERR! OTP required for authentication', +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.username({}), + {message: 'Authentication error. Use `npm whoami` to troubleshoot.'}, + ); +}); diff --git a/test/npm/util/verify-recent-npm-version.js b/test/npm/util/verify-recent-npm-version.js new file mode 100644 index 00000000..a5332d90 --- /dev/null +++ b/test/npm/util/verify-recent-npm-version.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('satisfied', createFixture, [{ + command: 'npm --version', + stdout: '7.20.0', // One higher than minimum +}], async ({t, testedModule: npm}) => { + await t.notThrowsAsync( + npm.verifyRecentNpmVersion(), + ); +}); + +test('not satisfied', createFixture, [{ + command: 'npm --version', + stdout: '7.18.0', // One lower than minimum +}], async ({t, testedModule: npm}) => { + await t.throwsAsync( + npm.verifyRecentNpmVersion(), + {message: '`np` requires npm >=7.19.0'}, + ); +}); diff --git a/test/prefix.js b/test/prefix.js deleted file mode 100644 index 7eb5ef8c..00000000 --- a/test/prefix.js +++ /dev/null @@ -1,25 +0,0 @@ -import test from 'ava'; -import esmock from 'esmock'; -import {stripIndent} from 'common-tags'; -import {getTagVersionPrefix} from '../source/util.js'; - -test('get tag prefix', async t => { - t.is(await getTagVersionPrefix({yarn: false}), 'v'); - t.is(await getTagVersionPrefix({yarn: true}), 'v'); -}); - -test('no options passed', async t => { - await t.throwsAsync(getTagVersionPrefix(), {message: stripIndent` - Expected argument to be of type \`object\` but received type \`undefined\` - Expected object to have keys \`["yarn"]\` - `}); - await t.throwsAsync(getTagVersionPrefix({}), {message: 'Expected object to have keys `["yarn"]`'}); -}); - -test.serial('defaults to "v" when command fails', async t => { - const testedModule = await esmock('../source/util.js', { - execa: {default: Promise.reject}, - }); - - t.is(await testedModule.getTagVersionPrefix({yarn: true}), 'v'); -}); diff --git a/test/preid.js b/test/preid.js deleted file mode 100644 index b83bcd84..00000000 --- a/test/preid.js +++ /dev/null @@ -1,16 +0,0 @@ -import test from 'ava'; -import {stripIndent} from 'common-tags'; -import {getPreReleasePrefix} from '../source/util.js'; - -test('get preId postfix', async t => { - t.is(await getPreReleasePrefix({yarn: false}), ''); - t.is(await getPreReleasePrefix({yarn: true}), ''); -}); - -test('no options passed', async t => { - await t.throwsAsync(getPreReleasePrefix(), {message: stripIndent` - Expected argument to be of type \`object\` but received type \`undefined\` - Expected object to have keys \`["yarn"]\` - `}); - await t.throwsAsync(getPreReleasePrefix({}), {message: 'Expected object to have keys `["yarn"]`'}); -}); diff --git a/test/release-task-helper.js b/test/release-task-helper.js new file mode 100644 index 00000000..a20ee673 --- /dev/null +++ b/test/release-task-helper.js @@ -0,0 +1,61 @@ +import test from 'ava'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +const verifyRelease = test.macro(async (t, {oldVersion, newVersion, prefixes = {}, like}) => { + const repoUrl = 'https://github.com/sindresorhus/np'; + + /** @type {import('../source/release-task-helper.js')} */ + const {default: releaseTaskHelper} = await esmock('../source/release-task-helper.js', import.meta.url, { + open: sinon.stub(), + '../source/util.js': { + getTagVersionPrefix: async () => prefixes.tag ?? 'v', + getPreReleasePrefix: async () => prefixes.preRelease ?? '', + }, + 'new-github-release-url': options_ => t.like(options_, {repoUrl, ...like}), + }); + + await releaseTaskHelper( + {version: newVersion, repoUrl, releaseNotes: sinon.stub()}, + {version: oldVersion}, + ); +}); + +// TODO: test `body` + +test('main', verifyRelease, { + oldVersion: '1.0.0', + newVersion: '1.1.0', + like: { + tag: 'v1.1.0', + isPrerelease: false, + }, +}); + +test('handles increment as new version', verifyRelease, { + oldVersion: '1.0.0', + newVersion: 'minor', + like: { + tag: 'v1.1.0', + isPrerelease: false, + }, +}); + +test('uses resolved prefix', verifyRelease, { + oldVersion: '1.0.0', + newVersion: '1.1.0', + prefixes: {tag: 'ver'}, + like: { + tag: 'ver1.1.0', + }, +}); + +test('prerelease', verifyRelease, { + oldVersion: '1.0.0', + newVersion: 'prerelease', + prefixes: {preRelease: 'beta'}, + like: { + tag: 'v1.0.1-beta.0', + isPrerelease: true, + }, +}); diff --git a/test/tasks/git-tasks.js b/test/tasks/git-tasks.js new file mode 100644 index 00000000..89d4aef7 --- /dev/null +++ b/test/tasks/git-tasks.js @@ -0,0 +1,184 @@ +import test from 'ava'; +import {SilentRenderer} from '../_helpers/listr-renderer.js'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {run, assertTaskFailed, assertTaskDoesntExist} from '../_helpers/listr.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-tasks.js', import.meta.url); + +test.afterEach(() => { + SilentRenderer.clearTasks(); +}); + +test.serial('should fail when release branch is not specified, current branch is not the release branch, and publishing from any branch not permitted', createFixture, [{ + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', +}], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Not on `master` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); + + assertTaskFailed(t, 'Check current branch'); +}); + +test.serial('should fail when current branch is not the specified release branch and publishing from any branch not permitted', createFixture, [{ + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', +}], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'release'})), + {message: 'Not on `release` branch. Use --any-branch to publish anyway, or set a different release branch using --branch.'}, + ); + + assertTaskFailed(t, 'Check current branch'); +}); + +test.serial('should not fail when current branch not master and publishing from any branch permitted', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'feature', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync( + run(gitTasks({anyBranch: true})), + ); + + assertTaskDoesntExist(t, 'Check current branch'); +}); + +test.serial('should fail when local working tree modified', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: 'M source/git-tasks.js', + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Unclean working tree. Commit or stash changes first.'}, + ); + + assertTaskFailed(t, 'Check local working tree'); +}); + +test.serial('should not fail when no remote set up', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + stderr: 'fatal: no upstream configured for branch \'master\'', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync( + run(gitTasks({branch: 'master'})), + ); +}); + +test.serial('should fail when remote history differs and changes are fetched', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '1', // Has unpulled changes + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); +}); + +test.serial('should fail when remote has unfetched changes', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + stdout: 'From https://github.com/sindresorhus/np', // Has unfetched changes + }, +], async ({t, testedModule: gitTasks}) => { + await t.throwsAsync( + run(gitTasks({branch: 'master'})), + {message: 'Remote history differs. Please run `git fetch` and pull changes.'}, + ); + + assertTaskFailed(t, 'Check remote history'); +}); + +test.serial('checks should pass when publishing from master, working tree is clean and remote history not different', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule: gitTasks}) => { + await t.notThrowsAsync( + run(gitTasks({branch: 'master'})), + ); +}); diff --git a/test/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js similarity index 53% rename from test/prerequisite-tasks.js rename to test/tasks/prerequisite-tasks.js index 9be09523..a2f8b304 100644 --- a/test/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -1,33 +1,25 @@ import process from 'node:process'; import test from 'ava'; -import {readPackageUp} from 'read-pkg-up'; -import Version from '../source/version.js'; -import actualPrerequisiteTasks from '../source/prerequisite-tasks.js'; -import {SilentRenderer} from './fixtures/listr-renderer.js'; -import { - _stubExeca, - run, - assertTaskFailed, - assertTaskDisabled, -} from './_utils.js'; - -/** @type {(...args: ReturnType<_stubExeca>) => Promise} */ -const stubExeca = _stubExeca('../source/prerequisite-tasks.js'); -const {packageJson: pkg} = await readPackageUp(); +import actualPrerequisiteTasks from '../../source/prerequisite-tasks.js'; +import {npPkg} from '../../source/util.js'; +import {SilentRenderer} from '../_helpers/listr-renderer.js'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {run, assertTaskFailed, assertTaskDisabled} from '../_helpers/listr.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/prerequisite-tasks.js', import.meta.url); test.afterEach(() => { SilentRenderer.clearTasks(); }); -test.serial('public-package published on npm registry: should fail when npm registry not pingable', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'npm ping', - exitCode: 1, - exitCodeName: 'EPERM', - stdout: '', - stderr: 'failed', - }]); - +test.serial('public-package published on npm registry: should fail when npm registry not pingable', createFixture, [{ + command: 'npm ping', + exitCode: 1, + exitCodeName: 'EPERM', + stdout: '', + stderr: 'failed', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('1.0.0', {name: 'test'}, {})), {message: 'Connection to npm registry failed'}, @@ -36,13 +28,10 @@ test.serial('public-package published on npm registry: should fail when npm regi assertTaskFailed(t, 'Ping npm registry'); }); -test.serial('private package: should disable task pinging npm registry', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }]); - +test.serial('private package: should disable task pinging npm registry', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), ); @@ -50,13 +39,10 @@ test.serial('private package: should disable task pinging npm registry', async t assertTaskDisabled(t, 'Ping npm registry'); }); -test.serial('external registry: should disable task pinging npm registry', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }]); - +test.serial('external registry: should disable task pinging npm registry', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {yarn: false})), ); @@ -64,68 +50,56 @@ test.serial('external registry: should disable task pinging npm registry', async assertTaskDisabled(t, 'Ping npm registry'); }); -test.serial('should fail when npm version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca([ - { - command: 'npm --version', - exitCode: 0, - stdout: '6.0.0', - }, - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }, - ]); - - const depRange = pkg.engines.npm; +test.serial('should fail when npm version does not match range in `package.json`', createFixture, [ + { + command: 'npm --version', + stdout: '6.0.0', + }, + { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', + }, +], async ({t, testedModule: prerequisiteTasks}) => { + const depRange = npPkg.engines.npm; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Please upgrade to npm${depRange}`}, + {message: `\`np\` requires npm ${depRange}`}, ); assertTaskFailed(t, 'Check npm version'); }); -test.serial('should fail when yarn version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca([ - { - command: 'yarn --version', - exitCode: 0, - stdout: '1.0.0', - }, - { - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }, - ]); - - const depRange = pkg.engines.yarn; +test.serial('should fail when yarn version does not match range in `package.json`', createFixture, [ + { + command: 'yarn --version', + stdout: '1.0.0', + }, + { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', + }, +], async ({t, testedModule: prerequisiteTasks}) => { + const depRange = npPkg.engines.yarn; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: true})), - {message: `Please upgrade to yarn${depRange}`}, + {message: `\`np\` requires yarn ${depRange}`}, ); assertTaskFailed(t, 'Check yarn version'); }); -test.serial('should fail when user is not authenticated at npm registry', async t => { - const prerequisiteTasks = await stubExeca([ - { - command: 'npm whoami', - exitCode: 0, - stdout: 'sindresorhus', - }, - { - command: 'npm access ls-collaborators test', - exitCode: 0, - stdout: '{"sindresorhus": "read"}', - }, - ]); - +test.serial('should fail when user is not authenticated at npm registry', createFixture, [ + { + command: 'npm whoami', + stdout: 'sindresorhus', + }, + { + command: 'npm access ls-collaborators test', + stdout: '{"sindresorhus": "read"}', + }, +], async ({t, testedModule: prerequisiteTasks}) => { process.env.NODE_ENV = 'P'; await t.throwsAsync( @@ -138,25 +112,20 @@ test.serial('should fail when user is not authenticated at npm registry', async assertTaskFailed(t, 'Verify user is authenticated'); }); -test.serial('should fail when user is not authenticated at external registry', async t => { - const prerequisiteTasks = await stubExeca([ - { - command: 'npm whoami --registry http://my.io', - exitCode: 0, - stdout: 'sindresorhus', - }, - { - command: 'npm access ls-collaborators test --registry http://my.io', - exitCode: 0, - stdout: '{"sindresorhus": "read"}', - }, - { - command: 'npm access list collaborators test --json --registry http://my.io', - exitCode: 0, - stdout: '{"sindresorhus": "read"}', - }, - ]); - +test.serial('should fail when user is not authenticated at external registry', createFixture, [ + { + command: 'npm whoami --registry http://my.io', + stdout: 'sindresorhus', + }, + { + command: 'npm access ls-collaborators test --registry http://my.io', + stdout: '{"sindresorhus": "read"}', + }, + { + command: 'npm access list collaborators test --json --registry http://my.io', + stdout: '{"sindresorhus": "read"}', + }, +], async ({t, testedModule: prerequisiteTasks}) => { process.env.NODE_ENV = 'P'; await t.throwsAsync( @@ -169,13 +138,12 @@ test.serial('should fail when user is not authenticated at external registry', a assertTaskFailed(t, 'Verify user is authenticated'); }); -test.serial('private package: should disable task `verify user is authenticated`', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - exitCode: 0, - stdout: '', - }]); +test.serial.todo('should not fail if no collaborators'); // Verify user is authenticated +test.serial('private package: should disable task `verify user is authenticated`', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { process.env.NODE_ENV = 'P'; await t.notThrowsAsync( @@ -187,31 +155,26 @@ test.serial('private package: should disable task `verify user is authenticated` assertTaskDisabled(t, 'Verify user is authenticated'); }); -test.serial('should fail when git version does not match range in `package.json`', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git version', - exitCode: 0, - stdout: 'git version 1.0.0', - }]); - - const depRange = pkg.engines.git; +test.serial('should fail when git version does not match range in `package.json`', createFixture, [{ + command: 'git version', + stdout: 'git version 1.0.0', +}], async ({t, testedModule: prerequisiteTasks}) => { + const depRange = npPkg.engines.git; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Please upgrade to git${depRange}`}, + {message: `\`np\` requires git ${depRange}`}, ); assertTaskFailed(t, 'Check git version'); }); -test.serial('should fail when git remote does not exist', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git ls-remote origin HEAD', - exitCode: 1, - exitCodeName: 'EPERM', - stderr: 'not found', - }]); - +test.serial('should fail when git remote does not exist', createFixture, [{ + command: 'git ls-remote origin HEAD', + exitCode: 1, + exitCodeName: 'EPERM', + stderr: 'not found', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), {message: 'not found'}, @@ -223,16 +186,16 @@ test.serial('should fail when git remote does not exist', async t => { test.serial('should fail when version is invalid', async t => { await t.throwsAsync( run(actualPrerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: `Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}, + {message: 'New version DDD should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version.'}, ); assertTaskFailed(t, 'Validate version'); }); -test.serial('should fail when version is lower as latest version', async t => { +test.serial('should fail when version is lower than latest version', async t => { await t.throwsAsync( run(actualPrerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {yarn: false})), - {message: 'New version `0.1.0` should be higher than current version `1.0.0`'}, + {message: 'New version 0.1.0 should be higher than current version 1.0.0.'}, ); assertTaskFailed(t, 'Validate version'); @@ -247,34 +210,28 @@ test.serial('should fail when prerelease version of public package without dist assertTaskFailed(t, 'Check for pre-release version'); }); -test.serial('should not fail when prerelease version of public package with dist tag given', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '', - }]); - +test.serial('should not fail when prerelease version of public package with dist tag given', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false, tag: 'pre'})), ); }); -test.serial('should not fail when prerelease version of private package without dist tag given', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '', - }]); - +test.serial('should not fail when prerelease version of private package without dist tag given', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), ); }); -test.serial('should fail when git tag already exists', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: 'vvb', - }]); - +test.serial('should fail when git tag already exists', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: 'vvb', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), {message: 'Git tag `v2.0.0` already exists.'}, @@ -283,12 +240,10 @@ test.serial('should fail when git tag already exists', async t => { assertTaskFailed(t, 'Check git tag existence'); }); -test.serial('checks should pass', async t => { - const prerequisiteTasks = await stubExeca([{ - command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', - stdout: '', - }]); - +test.serial('checks should pass', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), ); diff --git a/test/ui/new-files-dependencies.d.ts b/test/ui/new-files-dependencies.d.ts new file mode 100644 index 00000000..10f1387e --- /dev/null +++ b/test/ui/new-files-dependencies.d.ts @@ -0,0 +1,34 @@ +import type {Macro, ExecutionContext} from 'ava'; +import type {PackageJson} from 'read-pkg'; + +type Context = { + createFile: (file: string, content?: string) => Promise; +}; + +type CommandsFnParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDir: string; +}]; + +type ListItem = `- ${string}`; + +type Expected = { + unpublished: ListItem[]; + firstTime: ListItem[]; + dependencies: ListItem[]; +}; + +type AssertionsFnParameters = [{ + t: ExecutionContext; + $$: Execa$; + temporaryDir: string; + logs: string[]; +}]; + +export type CreateFixtureMacro = Macro<[ + pkg: PackageJson, + commands: (...arguments_: CommandsFnParameters) => Promise, + expected: Expected, + assertions: (...arguments_: AssertionsFnParameters) => Promise, +], Context>; diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js new file mode 100644 index 00000000..97a46172 --- /dev/null +++ b/test/ui/new-files-dependencies.js @@ -0,0 +1,127 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {execa} from 'execa'; +import {removePackageDependencies, updatePackage} from 'write-pkg'; +import stripAnsi from 'strip-ansi'; +import {readPackage} from 'read-pkg'; +import {createIntegrationTest} from '../_helpers/integration-test.js'; +import {mockInquirer} from '../_helpers/mock-inquirer.js'; + +/** @param {string} message */ +const checkLines = message => ( + /** @param {import('ava').ExecutionContext} t @param {string[]} logs @param {string[]} expectedLines */ + (t, logs, expectedLines) => { + const lineAfterMessage = logs.indexOf(message) + 1; + const endOfList = logs.findIndex((log, ind) => ind > lineAfterMessage && !log.startsWith('-')); + + t.deepEqual(logs.slice(lineAfterMessage, endOfList), expectedLines); + } +); + +const checkNewUnpublished = checkLines('The following new files will not be part of your published package:'); +const checkFirstTimeFiles = checkLines('The following new files will be published for the first time:'); +const checkNewDependencies = checkLines('The following new dependencies will be part of your published package:'); + +/** @type {import('./new-files-dependencies.d.ts').CreateFixtureMacro} */ +const createFixture = test.macro(async (t, pkg, commands, expected) => { + await createIntegrationTest(t, async ({$$, temporaryDir}) => { + pkg = { + name: '@np/foo', + version: '0.0.0', + dependencies: {}, + ...pkg, + }; + + await updatePackage(temporaryDir, pkg); + + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + + await commands({t, $$, temporaryDir}); + pkg = await readPackage({cwd: temporaryDir}); + + const {ui, logs: logsArray} = await mockInquirer({t, answers: {confirm: {confirm: false}}, mocks: { + './npm/util.js': { + getRegistryUrl: sinon.stub().resolves(''), + checkIgnoreStrategy: sinon.stub().resolves(), + }, + 'node:process': {cwd: () => temporaryDir}, + execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + 'is-interactive': () => false, + }}); + + await ui({runPublish: true, version: 'major', yarn: false}, {pkg, rootDir: temporaryDir}); + const logs = logsArray.join('').split('\n').map(log => stripAnsi(log)); + + const {unpublished, firstTime, dependencies} = expected; + + const assertions = await t.try(tt => { + if (unpublished) { + checkNewUnpublished(tt, logs, unpublished); + } + + if (firstTime) { + checkFirstTimeFiles(tt, logs, firstTime); + } + + if (dependencies) { + checkNewDependencies(tt, logs, dependencies); + } + }); + + if (!assertions.passed) { + t.log('logs:', logs); + t.log('pkg:', pkg); + t.log('expected:', expected); + } + + assertions.commit(); + }); +}); + +test('unpublished', createFixture, {files: ['*.js']}, async ({t, $$}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {unpublished: ['- new']}); + +test('unpublished and first time', createFixture, {files: ['*.js']}, async ({t, $$}) => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {unpublished: ['- new'], firstTime: ['- index.js']}); + +test('unpublished and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {unpublished: ['- new'], dependencies: ['- cat-names']}); + +test('first time', createFixture, {}, async ({t, $$}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; +}, {firstTime: ['- new']}); + +test('first time and dependencies', createFixture, {}, async ({t, $$, temporaryDir}) => { + await t.context.createFile('new'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {firstTime: ['- new'], dependencies: ['- cat-names']}); + +test('dependencies', createFixture, {dependencies: {'dog-names': '^2.1.0'}}, async ({temporaryDir}) => { + await removePackageDependencies(temporaryDir, ['dog-names']); + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {dependencies: ['- cat-names']}); + +test('unpublished and first time and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add .`; + await $$`git commit -m "added"`; + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, {unpublished: ['- new'], firstTime: ['- index.js'], dependencies: ['- cat-names']}); diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js new file mode 100644 index 00000000..52423b40 --- /dev/null +++ b/test/ui/prompts/tags.js @@ -0,0 +1,123 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {npPkg} from '../../../source/util.js'; +import {mockInquirer} from '../../_helpers/mock-inquirer.js'; + +const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { + const {ui, logs} = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + getRegistryUrl: sinon.stub().resolves(''), + checkIgnoreStrategy: sinon.stub().resolves(), + prereleaseTags: sinon.stub().resolves(tags), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves(`v${npPkg.version}`), + commitLogFromRevision: sinon.stub().resolves(''), + }, + }}); + + const results = await ui({ + runPublish: true, + availability: {}, + }, { + pkg: { + name: 'foo', + version, + files: ['*'], + }, + }); + + await assertions({t, results, logs}); +}); + +test('choose next', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + tag: 'next', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'next'); +}); + +test('choose beta', testUi, { + version: '0.0.0', + tags: ['beta', 'stable'], + answers: { + version: 'prerelease', + tag: 'beta', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'beta'); +}); + +test('choose custom', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + tag: 'Other (specify)', + customTag: 'alpha', + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'alpha'); +}); + +test('choose custom - validation', testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version: 'prerelease', + tag: 'Other (specify)', + customTag: [ + { + input: '', + error: 'Please specify a tag, for example, `next`.', + }, + { + input: 'latest', + error: 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.', + }, + { + input: 'LAteSt', + error: 'It\'s not possible to publish pre-releases under the `latest` tag. Please specify something else, for example, `next`.', + }, + { + input: 'alpha', + }, + ], + }, +}, ({t, results: {version, tag}}) => { + t.is(version.toString(), '0.0.1-0'); + t.is(tag, 'alpha'); +}); + +// Assuming from version 0.0.0 +const fixtures = [ + {version: 'premajor', expected: '1.0.0-0'}, + {version: 'preminor', expected: '0.1.0-0'}, + {version: 'prepatch', expected: '0.0.1-0'}, + {version: 'prerelease', expected: '0.0.1-0'}, +]; + +for (const {version, expected} of fixtures) { + test(`works for ${version}`, testUi, { + version: '0.0.0', + tags: ['next'], + answers: { + version, + tag: 'next', + }, + }, ({t, results: {version, tag}}) => { + t.is(version.toString(), expected); + t.is(tag, 'next'); + }); +} diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js new file mode 100644 index 00000000..eb1021fb --- /dev/null +++ b/test/ui/prompts/version.js @@ -0,0 +1,128 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {mockInquirer} from '../../_helpers/mock-inquirer.js'; + +const testUi = test.macro(async (t, {version, answers}, assertions) => { + const {ui, logs} = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + getRegistryUrl: sinon.stub().resolves(''), + checkIgnoreStrategy: sinon.stub().resolves(), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves(''), + }, + }}); + + const results = await ui({ + runPublish: false, + availability: {}, + }, { + pkg: { + name: 'foo', + version, + files: ['*'], + }, + }); + + await assertions({t, results, logs}); +}); + +test('choose major', testUi, { + version: '0.0.0', + answers: { + version: 'major', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0'); +}); + +test('choose minor', testUi, { + version: '0.0.0', answers: { + version: 'minor', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.1.0'); +}); + +test('choose patch', testUi, { + version: '0.0.0', answers: { + version: 'patch', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1'); +}); + +test('choose premajor', testUi, { + version: '0.0.0', answers: { + version: 'premajor', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0-0'); +}); + +test('choose preminor', testUi, { + version: '0.0.0', answers: { + version: 'preminor', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.1.0-0'); +}); + +test('choose prepatch', testUi, { + version: '0.0.0', answers: { + version: 'prepatch', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1-0'); +}); + +test('choose prerelease', testUi, { + version: '0.0.1-0', answers: { + version: 'prerelease', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '0.0.1-1'); +}); + +test('choose custom', testUi, { + version: '0.0.0', answers: { + version: 'Other (specify)', + customVersion: '1.0.0', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0'); +}); + +test('choose custom - validation', testUi, { + version: '1.0.0', answers: { + version: 'Other (specify)', + customVersion: [ + { + input: 'major', + error: 'Custom version should not be a SemVer increment.', + }, + { + input: '200', + error: 'Custom version 200 should be a valid SemVer version.', + }, + { + input: '0.0.0', + error: 'Custom version 0.0.0 should be higher than current version 1.0.0.', + }, + { + input: '1.0.0', + error: 'Custom version 1.0.0 should be higher than current version 1.0.0.', + }, + { + input: '2.0.0', + }, + ], + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '2.0.0'); +}); diff --git a/test/util/get-new-dependencies.js b/test/util/get-new-dependencies.js new file mode 100644 index 00000000..862a26ef --- /dev/null +++ b/test/util/get-new-dependencies.js @@ -0,0 +1,44 @@ +import test from 'ava'; +import {updatePackage} from 'write-pkg'; +import {readPackage} from 'read-pkg'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js'); + +test('reports new dependencies since last release', createFixture, async ({$$, temporaryDir}) => { + await updatePackage(temporaryDir, {dependencies: {'dog-names': '^2.1.0'}}); + await $$`git add .`; + await $$`git commit -m "added"`; + await $$`git tag v0.0.0`; + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { + const pkg = await readPackage({cwd: temporaryDir}); + + t.deepEqual( + await getNewDependencies(pkg, temporaryDir), + ['cat-names'], + ); +}); + +test('handles first time publish (no package.json in last release)', createFixture, async ({temporaryDir}) => { + await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { + const pkg = await readPackage({cwd: temporaryDir}); + + t.deepEqual( + await getNewDependencies(pkg, temporaryDir), + ['cat-names'], + ); +}); + +test('handles first time publish (no package.json in last release) - no deps', createFixture, async ({temporaryDir}) => { + await updatePackage(temporaryDir, {name: '@np/foo'}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { + const pkg = await readPackage({cwd: temporaryDir}); + + t.deepEqual( + await getNewDependencies(pkg, temporaryDir), + [], + ); +}); diff --git a/test/util/get-new-files.js b/test/util/get-new-files.js new file mode 100644 index 00000000..290f774f --- /dev/null +++ b/test/util/get-new-files.js @@ -0,0 +1,109 @@ +import path from 'node:path'; +import test from 'ava'; +import esmock from 'esmock'; +import {execa} from 'execa'; +import {writePackage} from 'write-pkg'; +import {createIntegrationTest} from '../_helpers/integration-test.js'; + +const createNewFilesFixture = test.macro(async (t, input, commands) => { + const {pkgFiles, expected: {unpublished, firstTime}} = input; + + await createIntegrationTest(t, async ({$$, temporaryDir}) => { + /** @type {import('../../source/util.js')} */ + const {getNewFiles} = await esmock('../../source/util.js', {}, { + 'node:process': {cwd: () => temporaryDir}, + execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + }); + + await commands({t, $$, temporaryDir}); + + await writePackage(temporaryDir, { + name: 'foo', + version: '0.0.0', + ...pkgFiles.length > 0 ? {files: pkgFiles} : {}, + }); + + const assertions = await t.try(async tt => { + tt.deepEqual( + await getNewFiles(temporaryDir), + {unpublished, firstTime}, + ); + }); + + if (!assertions.passed) { + t.log(input); + } + + assertions.commit(); + }); +}); + +test('files to package with tags added', createNewFilesFixture, { + pkgFiles: ['*.js'], + expected: { + unpublished: ['new'], + firstTime: ['index.js'], + }, +}, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('new'); + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}); + +test('file `new` to package without tags added', createNewFilesFixture, { + pkgFiles: ['index.js'], + expected: { + unpublished: ['new'], + firstTime: ['index.js', 'package.json'], + }, +}, async ({t}) => { + await t.context.createFile('new'); + await t.context.createFile('index.js'); +}); + +(() => { // Wrapper to have constants with macro + const longPath = path.join('veryLonggggggDirectoryName', 'veryLonggggggDirectoryName'); + const filePath1 = path.join(longPath, 'file1'); + const filePath2 = path.join(longPath, 'file2'); + + test('files with long pathnames added', createNewFilesFixture, { + pkgFiles: ['*.js'], + expected: { + unpublished: [filePath1, filePath2], + firstTime: [], + }, + }, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile(filePath1); + await t.context.createFile(filePath2); + await $$`git add -A`; + await $$`git commit -m "added"`; + }); +})(); + +test('no new files added', createNewFilesFixture, { + pkgFiles: [], + expected: { + unpublished: [], + firstTime: [], + }, +}, async ({$$}) => { + await $$`git tag v0.0.0`; +}); + +test('ignores .git and .github files', createNewFilesFixture, { + pkgFiles: ['*.js'], + expected: { + unpublished: [], + firstTime: ['index.js'], + }, +}, async ({t, $$}) => { + await $$`git tag v0.0.0`; + await t.context.createFile('.github/workflows/main.yml'); + await t.context.createFile('.github/pull_request_template.md'); + await t.context.createFile('index.js'); + await $$`git add -A`; + await $$`git commit -m "added"`; +}); diff --git a/test/util/get-pre-release-prefix.js b/test/util/get-pre-release-prefix.js new file mode 100644 index 00000000..113294c7 --- /dev/null +++ b/test/util/get-pre-release-prefix.js @@ -0,0 +1,72 @@ +import process from 'node:process'; +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {getPreReleasePrefix as originalGetPreReleasePrefix} from '../../source/util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js', import.meta.url); + +test('returns preid postfix if set - npm', createFixture, [{ + command: 'npm config get preid', + stdout: 'pre', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({yarn: false}), + 'pre', + ); +}); + +test('returns preid postfix if set - yarn', createFixture, [{ + command: 'yarn config get preid', + stdout: 'pre', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({yarn: true}), + 'pre', + ); +}); + +test('returns empty string if not set - npm', createFixture, [{ + command: 'npm config get preid', + stdout: 'undefined', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({yarn: false}), + '', + ); +}); + +test('returns empty string if not set - yarn', createFixture, [{ + command: 'yarn config get preid', + stdout: 'undefined', +}], async ({t, testedModule: {getPreReleasePrefix}}) => { + t.is( + await getPreReleasePrefix({yarn: true}), + '', + ); +}); + +test('no options passed', async t => { + await t.throwsAsync( + originalGetPreReleasePrefix(), + {message: stripIndent` + Expected argument to be of type \`object\` but received type \`undefined\` + Expected object to have keys \`["yarn"]\` + `}, + ); + + await t.throwsAsync( + originalGetPreReleasePrefix({}), + {message: 'Expected object to have keys `["yarn"]`'}, + ); +}); + +test.serial('returns actual value', async t => { + const originalPreid = process.env.NPM_CONFIG_PREID; + process.env.NPM_CONFIG_PREID = 'beta'; + + t.is(await originalGetPreReleasePrefix({yarn: false}), 'beta'); + + process.env.NPM_CONFIG_PREID = originalPreid; +}); diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js new file mode 100644 index 00000000..aa387110 --- /dev/null +++ b/test/util/get-tag-version-prefix.js @@ -0,0 +1,52 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import {_createFixture} from '../_helpers/stub-execa.js'; +import {getTagVersionPrefix as originalGetTagVersionPrefix} from '../../source/util.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js', import.meta.url); + +test('returns tag prefix - npm', createFixture, [{ + command: 'npm config get tag-version-prefix', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix({yarn: false}), + 'ver', + ); +}); + +test('returns preId postfix - yarn', createFixture, [{ + command: 'yarn config get version-tag-prefix', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix({yarn: true}), + 'ver', + ); +}); + +test('defaults to "v" when command fails', createFixture, [{ + command: 'npm config get tag-version-prefix', + exitCode: 1, +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix({yarn: false}), + 'v', + ); +}); + +test('no options passed', async t => { + await t.throwsAsync( + originalGetTagVersionPrefix(), + {message: stripIndent` + Expected argument to be of type \`object\` but received type \`undefined\` + Expected object to have keys \`["yarn"]\` + `}, + ); + + await t.throwsAsync( + originalGetTagVersionPrefix({}), + {message: 'Expected object to have keys `["yarn"]`'}, + ); +}); diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js new file mode 100644 index 00000000..8ddfb6fe --- /dev/null +++ b/test/util/hyperlinks.js @@ -0,0 +1,76 @@ +import test from 'ava'; +import esmock from 'esmock'; + +const MOCK_REPO_URL = 'https://github.com/unicorn/rainbow'; +const MOCK_COMMIT_HASH = '5063f8a'; +const MOCK_COMMIT_RANGE = `${MOCK_COMMIT_HASH}...master`; + +const verifyLinks = test.macro(async (t, {linksSupported}, assertions) => { + /** @type {typeof import('../../source/util.js')} */ + const util = await esmock('../../source/util.js', {}, { + 'supports-hyperlinks': { + stdout: linksSupported, + stderr: linksSupported, + }, + }); + + await assertions({t, util}); +}); + +test('linkifyIssues correctly links issues', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyIssues}}) => { + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes #3 #4'), 'Commit message - fixes ]8;;https://github.com/unicorn/rainbow/issues/3#3]8;; ]8;;https://github.com/unicorn/rainbow/issues/4#4]8;;'); + t.is(linkifyIssues(MOCK_REPO_URL, 'Commit message - fixes foo/bar#4'), 'Commit message - fixes ]8;;https://github.com/foo/bar/issues/4foo/bar#4]8;;'); +}); + +test('linkifyIssues returns raw message if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyIssues}}) => { + const message = 'Commit message - fixes #5'; + t.is(linkifyIssues(undefined, message), message); +}); + +test('linkifyIssues returns raw message if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyIssues}}) => { + const message = 'Commit message - fixes #6'; + t.is(linkifyIssues(MOCK_REPO_URL, message), message); +}); + +test('linkifyCommit correctly links commits', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), ']8;;https://github.com/unicorn/rainbow/commit/5063f8a5063f8a]8;;'); +}); + +test('linkifyCommit returns raw commit hash if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(undefined, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); +}); + +test('linkifyCommit returns raw commit hash if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyCommit}}) => { + t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); +}); + +test('linkifyCommitRange returns raw commitRange if url is not provided', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(undefined, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); +}); + +test('linkifyCommitRange returns raw commitRange if terminalLink is not supported', verifyLinks, { + linksSupported: false, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); +}); + +test('linkifyCommitRange correctly links commit range', verifyLinks, { + linksSupported: true, +}, ({t, util: {linkifyCommitRange}}) => { + t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), ']8;;https://github.com/unicorn/rainbow/compare/5063f8a...master5063f8a...master]8;;'); +}); diff --git a/test/util/join-list.js b/test/util/join-list.js new file mode 100644 index 00000000..3257adf9 --- /dev/null +++ b/test/util/join-list.js @@ -0,0 +1,23 @@ +import test from 'ava'; +import stripAnsi from 'strip-ansi'; +import {joinList} from '../../source/util.js'; + +const testJoinList = test.macro((t, {list, expected}) => { + const output = joinList(list); + t.is(stripAnsi(output), expected); +}); + +test('one item', testJoinList, { + list: ['foo'], + expected: '- foo', +}); + +test('two items', testJoinList, { + list: ['foo', 'bar'], + expected: '- foo\n- bar', +}); + +test('multiple items', testJoinList, { + list: ['foo', 'bar', 'baz'], + expected: '- foo\n- bar\n- baz', +}); diff --git a/test/util/read-pkg.js b/test/util/read-pkg.js new file mode 100644 index 00000000..e9dd49c8 --- /dev/null +++ b/test/util/read-pkg.js @@ -0,0 +1,49 @@ +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import test from 'ava'; +import esmock from 'esmock'; +import {temporaryDirectory} from 'tempy'; +import {readPkg, npPkg, npRootDir} from '../../source/util.js'; + +const rootDir = fileURLToPath(new URL('../..', import.meta.url)).slice(0, -1); + +test('without packagePath returns np package.json', async t => { + const {pkg, rootDir: pkgDir} = await readPkg(); + + t.is(pkg.name, 'np'); + t.is(pkgDir, rootDir); +}); + +test('with packagePath', async t => { + const fixtureDir = path.resolve(rootDir, 'test/fixtures/files/one-file'); + const {pkg, rootDir: pkgDir} = await readPkg(fixtureDir); + + t.is(pkg.name, 'foo'); + t.is(pkgDir, fixtureDir); +}); + +test('no package.json', async t => { + await t.throwsAsync( + readPkg(temporaryDirectory()), + {message: 'No `package.json` found. Make sure the current directory is a valid package.'}, + ); +}); + +test('npPkg', t => { + t.is(npPkg.name, 'np'); +}); + +test('npRootDir', t => { + t.is(npRootDir, rootDir); +}); + +test('npRootDir is correct when process.cwd is different', async t => { + const temporaryDir = temporaryDirectory(); + + /** @type {import('../../source/util.js')} */ + const util = await esmock('../../source/util.js', {}, { + 'node:process': {cwd: temporaryDir}, + }); + + t.is(util.npRootDir, rootDir); +}); diff --git a/test/util/validate-engine-version-satisfies.js b/test/util/validate-engine-version-satisfies.js new file mode 100644 index 00000000..6bd75602 --- /dev/null +++ b/test/util/validate-engine-version-satisfies.js @@ -0,0 +1,24 @@ +import test from 'ava'; +import {validateEngineVersionSatisfies, npPkg} from '../../source/util.js'; + +const testEngineRanges = test.macro((t, engine, {above, below}) => { + const range = npPkg.engines[engine]; + + t.notThrows( + () => validateEngineVersionSatisfies(engine, above), // One above minimum + ); + + t.throws( + () => validateEngineVersionSatisfies(engine, below), // One below minimum + {message: `\`np\` requires ${engine} ${range}`}, + ); +}); + +test('node', testEngineRanges, 'node', {above: '16.7.0', below: '16.5.0'}); + +test('npm', testEngineRanges, 'npm', {above: '7.20.0', below: '7.18.0'}); + +test('git', testEngineRanges, 'git', {above: '2.12.0', below: '2.10.0'}); + +test('yarn', testEngineRanges, 'yarn', {above: '1.8.0', below: '1.6.0'}); + diff --git a/test/version.js b/test/version.js index 203fb4e2..30e203f5 100644 --- a/test/version.js +++ b/test/version.js @@ -1,139 +1,234 @@ import test from 'ava'; +import sinon from 'sinon'; +import {template as chalk} from 'chalk-template'; +import semver from 'semver'; import Version from '../source/version.js'; -test('version.SEMVER_INCREMENTS', t => { - t.deepEqual(Version.SEMVER_INCREMENTS, ['patch', 'minor', 'major', 'prepatch', 'preminor', 'premajor', 'prerelease']); +const INCREMENT_LIST = 'patch, minor, major, prepatch, preminor, premajor, prerelease'; +const INCREMENT_LIST_OR = 'patch, minor, major, prepatch, preminor, premajor, or prerelease'; + +/** @param {string} input - Place `{ }` around the version parts to be highlighted. */ +const makeNewFormattedVersion = input => { + input = input.replaceAll(/{([^}]*)}/g, '{cyan $1}'); // https://regex101.com/r/rZUIp4/1 + return chalk(`{dim ${input}}`); +}; + +test('new Version - valid', t => { + t.is(new Version('1.0.0').toString(), '1.0.0'); }); -test('version.PRERELEASE_VERSIONS', t => { - t.deepEqual(Version.PRERELEASE_VERSIONS, ['prepatch', 'preminor', 'premajor', 'prerelease']); +test('new Version - invalid', t => { + t.throws( + () => new Version('major'), + {message: 'Version major should be a valid SemVer version.'}, + ); }); -test('version.isValidInput', t => { - t.false(Version.isValidInput(null)); - t.false(Version.isValidInput('foo')); - t.false(Version.isValidInput('1.0.0.0')); +test('new Version - valid w/ valid increment', t => { + t.is(new Version('1.0.0', 'major').toString(), '2.0.0'); +}); - t.true(Version.isValidInput('patch')); - t.true(Version.isValidInput('minor')); - t.true(Version.isValidInput('major')); - t.true(Version.isValidInput('prepatch')); - t.true(Version.isValidInput('preminor')); - t.true(Version.isValidInput('premajor')); - t.true(Version.isValidInput('prerelease')); - t.true(Version.isValidInput('1.0.0')); - t.true(Version.isValidInput('1.1.0')); - t.true(Version.isValidInput('1.0.1')); - t.true(Version.isValidInput('1.0.0-beta')); - t.true(Version.isValidInput('2.0.0-rc.2')); +test('new Version - invalid w/ valid increment', t => { + t.throws( + () => new Version('major', 'major'), + {message: 'Version major should be a valid SemVer version.'}, + ); }); -test('version.isPrerelease', t => { - t.false(new Version('1.0.0').isPrerelease()); - t.false(new Version('1.1.0').isPrerelease()); - t.false(new Version('1.0.1').isPrerelease()); +test('new Version - valid w/ invalid increment', t => { + t.throws( + () => new Version('1.0.0', '2.0.0'), + {message: `Increment 2.0.0 should be one of ${INCREMENT_LIST_OR}.`}, + ); +}); - t.true(new Version('1.0.0-beta').isPrerelease()); - t.true(new Version('2.0.0-rc.2').isPrerelease()); +test('new Version - invalid w/ invalid increment', t => { + t.throws( + () => new Version('major', '2.0.0'), + {message: 'Version major should be a valid SemVer version.'}, + ); }); -test('version.isPrereleaseOrIncrement', t => { - t.false(Version.isPrereleaseOrIncrement('patch')); - t.false(Version.isPrereleaseOrIncrement('minor')); - t.false(Version.isPrereleaseOrIncrement('major')); +// Input as SemVer increment is covered in constructor tests +test('setFrom - valid input as version', t => { + t.is(new Version('1.0.0').setFrom('2.0.0').toString(), '2.0.0'); +}); - t.true(Version.isPrereleaseOrIncrement('prepatch')); - t.true(Version.isPrereleaseOrIncrement('preminor')); - t.true(Version.isPrereleaseOrIncrement('premajor')); - t.true(Version.isPrereleaseOrIncrement('prerelease')); +test('setFrom - invalid input as version', t => { + t.throws( + () => new Version('1.0.0').setFrom('200'), + {message: `New version 200 should either be one of ${INCREMENT_LIST}, or a valid SemVer version.`}, + ); }); -test('version.getNewVersionFrom', t => { - const message = 'Version should be either patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid semver version.'; +test('setFrom - valid input is not higher than version', t => { + t.throws( + () => new Version('1.0.0').setFrom('0.2.0'), + {message: 'New version 0.2.0 should be higher than current version 1.0.0.'}, + ); +}); - t.throws(() => new Version('1.0.0').getNewVersionFrom('patchxxx'), {message}); - t.throws(() => new Version('1.0.0').getNewVersionFrom('1.0.0.0'), {message}); +test('format', t => { + t.is(new Version('0.0.0').format(), makeNewFormattedVersion('0.0.0')); +}); - t.is(new Version('1.0.0').getNewVersionFrom('patch'), '1.0.1'); - t.is(new Version('1.0.0').getNewVersionFrom('minor'), '1.1.0'); - t.is(new Version('1.0.0').getNewVersionFrom('major'), '2.0.0'); +test('format - set diff', t => { + t.is( + new Version('1.0.0').format({previousVersion: '0.0.0'}), + makeNewFormattedVersion('{1}.0.0'), + ); +}); - t.is(new Version('1.0.0-beta').getNewVersionFrom('major'), '1.0.0'); - t.is(new Version('1.0.0').getNewVersionFrom('prepatch'), '1.0.1-0'); - t.is(new Version('1.0.1-0').getNewVersionFrom('prepatch'), '1.0.2-0'); +test('format - major', t => { + const newVersion = makeNewFormattedVersion('{1}.0.0'); - t.is(new Version('1.0.0-0').getNewVersionFrom('prerelease'), '1.0.0-1'); - t.is(new Version('1.0.1-0').getNewVersionFrom('prerelease'), '1.0.1-1'); + t.is(new Version('0.0.0').setFrom('major').format(), newVersion); + t.is(new Version('0.0.0').setFrom('1.0.0').format(), newVersion); }); -test('version.validate', t => { - const message = 'Version should be a valid semver version.'; +test('format - minor', t => { + const newVersion = makeNewFormattedVersion('0.{1}.0'); + + t.is(new Version('0.0.0').setFrom('minor').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.1.0').format(), newVersion); +}); - t.throws(() => Version.validate('patch'), {message}); - t.throws(() => Version.validate('patchxxx'), {message}); - t.throws(() => Version.validate('1.0.0.0'), {message}); +test('format - patch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}'); - t.notThrows(() => Version.validate('1.0.0')); - t.notThrows(() => Version.validate('1.0.0-beta')); - t.notThrows(() => Version.validate('1.0.0-0')); + t.is(new Version('0.0.0').setFrom('patch').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1').format(), newVersion); }); -test('version.isGreaterThanOrEqualTo', t => { - t.false(new Version('1.0.0').isGreaterThanOrEqualTo('0.0.1')); - t.false(new Version('1.0.0').isGreaterThanOrEqualTo('0.1.0')); +test('format - premajor', t => { + const newVersion = makeNewFormattedVersion('{1}.0.0-{0}'); - t.false(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0-0')); - t.false(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0-beta')); + t.is(new Version('0.0.0').setFrom('premajor').format(), newVersion); + t.is(new Version('0.0.0').setFrom('1.0.0-0').format(), newVersion); +}); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.0')); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.0.1')); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('1.1.0')); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0')); +test('format - preminor', t => { + const newVersion = makeNewFormattedVersion('0.{1}.0-{0}'); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0-0')); - t.true(new Version('1.0.0').isGreaterThanOrEqualTo('2.0.0-beta')); + t.is(new Version('0.0.0').setFrom('preminor').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.1.0-0').format(), newVersion); }); -test('version.isLowerThanOrEqualTo', t => { - t.true(new Version('1.0.0').isLowerThanOrEqualTo('0.0.1')); - t.true(new Version('1.0.0').isLowerThanOrEqualTo('0.1.0')); +test('format - prepatch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); - t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0-0')); - t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0-beta')); - t.true(new Version('1.0.0').isLowerThanOrEqualTo('1.0.0')); + t.is(new Version('0.0.0').setFrom('prepatch').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1-0').format(), newVersion); +}); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('1.0.1')); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('1.1.0')); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0')); +test('format - prerelease', t => { + const newVersion = makeNewFormattedVersion('0.0.0-{1}'); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0-0')); - t.false(new Version('1.0.0').isLowerThanOrEqualTo('2.0.0-beta')); + t.is(new Version('0.0.0-0').setFrom('prerelease').format(), newVersion); + t.is(new Version('0.0.0-0').setFrom('0.0.0-1').format(), newVersion); }); -test('version.satisfies', t => { +test('format - prerelease as prepatch', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0}'); + + t.is(new Version('0.0.0').setFrom('prerelease').format(), newVersion); + t.is(new Version('0.0.0').setFrom('0.0.1-0').format(), newVersion); +}); + +test('format - prerelease with multiple numbers', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{0.0}'); + t.is(new Version('0.0.0').setFrom('0.0.1-0.0').format(), newVersion); +}); + +test('format - prerelease with text', t => { + const newVersion = makeNewFormattedVersion('0.0.{1}-{alpha.0}'); + t.is(new Version('0.0.0').setFrom('0.0.1-alpha.0').format(), newVersion); +}); + +test('format - prerelease diffs', t => { + const newVersion = makeNewFormattedVersion('0.0.0-1.{2}'); + + t.is( + new Version('0.0.0-1.1').setFrom('0.0.0-1.2').format({previousVersion: '0.0.0-1.1'}), + newVersion, + ); + + t.is( + new Version('0.0.0-1.2').format({previousVersion: '0.0.0-1.1'}), + newVersion, + ); +}); + +test('format - custom colors', t => { + t.is( + new Version('1.2.3').format({color: 'green'}), + chalk('{green 1.2.3}'), + ); + + t.is( + new Version('1.2.3', 'minor').format({diffColor: 'red'}), + chalk('{dim 1.{red 3}.0}'), + ); + + t.is( + new Version('1.2.3', 'patch').format({color: 'bgBlack.red', diffColor: 'yellow'}), + chalk('{bgBlack.red 1.2.{yellow 4}}'), + ); + + t.is( + new Version('1.2.3', 'prerelease').format({color: 'bgBlack.red', diffColor: 'yellow'}), + chalk('{bgBlack.red 1.2.{yellow 4}-{yellow 0}}'), + ); +}); + +test('format - previousVersion as SemVer instance', t => { + const previousVersion = semver.parse('0.0.0'); + const newVersion = makeNewFormattedVersion('{1}.0.0'); + + const spy = sinon.spy(semver, 'parse'); + + t.is(new Version('1.0.0').format({previousVersion}), newVersion); + t.true(spy.calledOnce, 'semver.parse was called for previousVersion!'); + + spy.resetHistory(); + + t.is(new Version('1.0.0').format({previousVersion: '0.0.0'}), newVersion); + t.true(spy.calledTwice, 'semver.parse was not called for previousVersion!'); +}); + +test('format - invalid previousVersion', t => { + t.throws( + () => new Version('1.0.0').format({previousVersion: '000'}), + {message: 'Previous version 000 should be a valid SemVer version.'}, + ); +}); + +test('satisfies', t => { t.true(new Version('2.15.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.true(new Version('2.99.8').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.true(new Version('3.10.1').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.true(new Version('6.7.0-next.0').satisfies('<6.8.0')); + t.false(new Version('3.0.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); t.false(new Version('3.10.0').satisfies('>=2.15.8 <3.0.0 || >=3.10.1')); -}); - -test('version.getAndValidateNewVersionFrom', t => { - t.is(Version.getAndValidateNewVersionFrom('patch', '1.0.0'), '1.0.1'); t.throws( - () => Version.getAndValidateNewVersionFrom('patch', '1'), - {message: 'Version should be a valid semver version.'}, + () => new Version('1.2.3').satisfies('=>1.0.0'), + {message: 'Range =>1.0.0 is not a valid SemVer range.'}, ); +}); - t.throws( - () => Version.getAndValidateNewVersionFrom('lol', '1.0.0'), - {message: `Version should be either ${Version.SEMVER_INCREMENTS.join(', ')}, or a valid semver version.`}, - ); +test('isPrerelease', t => { + t.false(new Version('1.0.0').isPrerelease()); + t.false(new Version('1.1.0').isPrerelease()); + t.false(new Version('1.0.1').isPrerelease()); - t.throws( - () => Version.getAndValidateNewVersionFrom('1.0.0', '2.0.0'), - {message: 'New version `1.0.0` should be higher than current version `2.0.0`'}, - ); + t.true(new Version('1.0.0-alpha.1').isPrerelease()); + t.true(new Version('1.0.0-beta').isPrerelease()); + t.true(new Version('2.0.0-rc.2').isPrerelease()); +}); + +test('optionally set prereleasePrefix', t => { + t.is(new Version('1.0.0', 'prerelease', {prereleasePrefix: 'alpha'}).toString(), '1.0.1-alpha.0'); + t.is(new Version('1.0.0').setFrom('prerelease', {prereleasePrefix: 'beta'}).toString(), '1.0.1-beta.0'); }); From c31c2bca2641b9eb4e0fee7da1f2231cc92ad4d9 Mon Sep 17 00:00:00 2001 From: Corentin Girard Date: Thu, 2 Nov 2023 20:26:06 +0100 Subject: [PATCH 065/180] Group unpublished files in folders (#716) --- source/ui.js | 2 +- source/util.js | 22 ++++++++++++++++++++++ test/util/auto-group-list.js | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 test/util/auto-group-list.js diff --git a/source/ui.js b/source/ui.js index 29f1c980..44931301 100644 --- a/source/ui.js +++ b/source/ui.js @@ -93,7 +93,7 @@ const checkNewFilesAndDependencies = async (pkg, rootDir) => { const messages = []; if (newFiles.unpublished.length > 0) { - messages.push(`The following new files will not be part of your published package:\n${util.joinList(newFiles.unpublished)}\n\nIf you intended to publish them, add them to the \`files\` field in package.json.`); + 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) { diff --git a/source/util.js b/source/util.js index 8d31e6ec..b58fa1b9 100644 --- a/source/util.js +++ b/source/util.js @@ -76,6 +76,28 @@ export const getTagVersionPrefix = pMemoize(async options => { export const joinList = list => chalk.reset(list.map(item => `- ${item}`).join('\n')); +export const groupFilesInFolders = (files, groupingMinimumDepth = 1, groupingThresholdCount = 5) => { + const groups = {}; + for (const file of files) { + const groupKey = path.join(...file.split(path.sep).slice(0, groupingMinimumDepth)); + groups[groupKey] = [...groups[groupKey] ?? [], file]; + } + + const lines = []; + for (const [folder, filesInFolder] of Object.entries(groups)) { + if (filesInFolder.length > groupingThresholdCount) { + lines.push(`- ${folder}/* ${chalk.bold.white(`(${filesInFolder.length} files)`)}`); + continue; + } + + for (const file of filesInFolder) { + lines.push(`- ${file}`); + } + } + + return chalk.reset(lines.join('\n')); +}; + export const getNewFiles = async rootDir => { const listNewFiles = await git.newFilesSinceLastRelease(rootDir); const listPkgFiles = await npm.getFilesToBePacked(rootDir); diff --git a/test/util/auto-group-list.js b/test/util/auto-group-list.js new file mode 100644 index 00000000..331d1b28 --- /dev/null +++ b/test/util/auto-group-list.js @@ -0,0 +1,33 @@ +import test from 'ava'; +import stripAnsi from 'strip-ansi'; +import {groupFilesInFolders} from '../../source/util.js'; + +const testJoinList = test.macro((t, {list, expected}) => { + const output = groupFilesInFolders(list); + t.is(stripAnsi(output), expected); +}); + +test('one item', testJoinList, { + list: [ + 'scripts/a.sh', + ], + expected: '- scripts/a.sh', +}); + +test('mix of collapsed and expanded folders', testJoinList, { + list: [ + 'scripts/a.sh', + 'scripts/b.sh', + 'scripts/c.sh', + 'test/_utils-1.js', + 'test/_utils-2.js', + 'test/_utils-3.js', + 'test/_utils-4.js', + 'test/_utils-5.js', + 'test/_utils-6.js', + ], + expected: `- scripts/a.sh +- scripts/b.sh +- scripts/c.sh +- test/* (6 files)`, +}); From 6c2e00ebdc8cf3a20634008fd5c02f2b11c9565f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 4 Nov 2023 15:02:15 +0700 Subject: [PATCH 066/180] Require Node.js 18 and npm 9 --- .github/workflows/main.yml | 6 ++-- package.json | 32 +++++++++---------- readme.md | 8 ++--- source/config.js | 30 ++++++++--------- source/npm/util.js | 11 ++----- source/util.js | 2 +- test/npm/util/collaborators.js | 9 ++---- test/npm/util/verify-recent-npm-version.js | 6 ++-- test/tasks/prerequisite-tasks.js | 6 +--- test/ui/new-files-dependencies.js | 2 +- test/util/get-new-dependencies.js | 2 +- test/util/get-new-files.js | 2 +- .../util/validate-engine-version-satisfies.js | 12 +++---- 13 files changed, 53 insertions(+), 75 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2d58c737..2a7831da 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,12 +10,10 @@ jobs: fail-fast: false matrix: node-version: - - 19 - 18 - - 16 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: git config --global user.name "Github Actions" diff --git a/package.json b/package.json index 9de8bdfb..2b1728a7 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "type": "module", "bin": "source/cli.js", "engines": { - "node": ">=16.6.0", - "npm": ">=7.19.0", + "node": ">=18", + "npm": ">=9", "git": ">=2.11.0", "yarn": ">=1.7.0" }, @@ -33,7 +33,7 @@ "dependencies": { "chalk": "^5.3.0", "chalk-template": "^1.1.0", - "cosmiconfig": "^8.1.3", + "cosmiconfig": "^8.3.6", "del": "^7.1.0", "escape-goat": "^4.0.0", "escape-string-regexp": "^5.0.0", @@ -41,47 +41,47 @@ "exit-hook": "^4.0.0", "github-url-from-git": "^1.5.0", "has-yarn": "^3.0.0", - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^7.0.1", "ignore-walk": "^6.0.3", "import-local": "^3.1.0", - "inquirer": "^9.2.10", + "inquirer": "^9.2.11", "is-installed-globally": "^0.4.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", "issue-regex": "^4.1.0", "listr": "^0.14.3", "listr-input": "^0.2.1", - "log-symbols": "^5.1.0", + "log-symbols": "^6.0.0", "meow": "^12.1.1", "new-github-release-url": "^2.0.0", - "npm-name": "^7.1.0", + "npm-name": "^7.1.1", "onetime": "^6.0.0", "open": "^9.1.0", "ow": "^1.1.1", "p-memoize": "^7.1.1", "p-timeout": "^6.1.2", "path-exists": "^5.0.0", - "pkg-dir": "^7.0.0", - "read-pkg": "^8.1.0", - "read-pkg-up": "^10.1.0", + "pkg-dir": "^8.0.0", + "read-pkg": "^9.0.0", + "read-package-up": "^11.0.0", "rxjs": "^7.8.1", "semver": "^7.5.4", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", - "update-notifier": "^6.0.2" + "update-notifier": "^7.0.0" }, "devDependencies": { - "@sindresorhus/is": "^6.0.0", - "@types/semver": "^7.5.1", + "@sindresorhus/is": "^6.1.0", + "@types/semver": "^7.5.4", "ava": "^5.3.1", "common-tags": "^1.8.2", - "esmock": "^2.3.8", + "esmock": "^2.5.9", "fs-extra": "^11.1.1", "map-obj": "^5.0.2", - "sinon": "^15.2.0", + "sinon": "^17.0.1", "strip-ansi": "^7.1.0", "tempy": "^3.1.0", - "write-pkg": "^6.0.0", + "write-package": "^7.0.0", "xo": "^0.56.0" }, "ava": { diff --git a/readme.md b/readme.md index d31d7b25..e5676ad9 100644 --- a/readme.md +++ b/readme.md @@ -54,8 +54,8 @@ ## Prerequisite -- Node.js 16 or later -- npm 7.19.0 or later +- Node.js 18 or later +- npm 9 or later - Git 2.11 or later ## Install @@ -340,14 +340,12 @@ npm ERR! code E403 npm ERR! 403 Forbidden - GET https://registry.yarnpkg.com/-/package/my-awesome-package/collaborators?format=cli - Forbidden ``` -…please check whether the command `npm access ls-collaborators my-awesome-package` succeeds. If it doesn't, Yarn has overwritten your registry URL. To fix this, add the correct registry URL to `package.json`: +…please check whether the command `npm access list collaborators my-awesome-package` succeeds. If it doesn't, Yarn has overwritten your registry URL. To fix this, add the correct registry URL to `package.json`: ```json "publishConfig": { "registry": "https://registry.npmjs.org" } - -Note: On `npm` v9+, the command has been changed to `npm access list collaborators my-awesome-package`. ``` ## Maintainers diff --git a/source/config.js b/source/config.js index 6aa41a57..fec3e03b 100644 --- a/source/config.js +++ b/source/config.js @@ -2,30 +2,26 @@ import os from 'node:os'; import isInstalledGlobally from 'is-installed-globally'; import {cosmiconfig} from 'cosmiconfig'; -// TODO: Remove when cosmiconfig/cosmiconfig#283 lands -const loadESM = async filepath => { - const module = await import(filepath); - return module.default ?? module; -}; +export default async function getConfig(rootDirectory) { + const searchDirectory = isInstalledGlobally ? os.homedir() : rootDirectory; + + const searchPlaces = [ + '.np-config.json', + '.np-config.js', + '.np-config.cjs', + '.np-config.mjs', + ]; -const getConfig = async rootDir => { - const searchDir = isInstalledGlobally ? os.homedir() : rootDir; - const searchPlaces = ['.np-config.json', '.np-config.js', '.np-config.cjs', '.np-config.mjs']; if (!isInstalledGlobally) { searchPlaces.push('package.json'); } const explorer = cosmiconfig('np', { searchPlaces, - stopDir: searchDir, - loaders: { - '.js': loadESM, - '.mjs': loadESM, - }, + stopDir: searchDirectory, }); - const {config} = (await explorer.search(searchDir)) || {}; - return config; -}; + const {config} = (await explorer.search(searchDirectory)) ?? {}; -export default getConfig; + return config; +} diff --git a/source/npm/util.js b/source/npm/util.js index 792e1379..4954cbf4 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -5,7 +5,6 @@ import pTimeout from 'p-timeout'; import ow from 'ow'; import npmName from 'npm-name'; import chalk from 'chalk-template'; -import Version from '../version.js'; import * as util from '../util.js'; export const version = async () => { @@ -51,18 +50,14 @@ export const collaborators = async pkg => { const packageName = pkg.name; ow(packageName, ow.string); - const npmVersion = await version(); - // TODO: Remove old command when targeting Node.js 18 - const args = new Version(npmVersion).satisfies('>=9.0.0') - ? ['access', 'list', 'collaborators', packageName, '--json'] - : ['access', 'ls-collaborators', packageName]; + const arguments_ = ['access', 'list', 'collaborators', packageName, '--json']; if (isExternalRegistry(pkg)) { - args.push('--registry', pkg.publishConfig.registry); + arguments_.push('--registry', pkg.publishConfig.registry); } try { - const {stdout} = await execa('npm', args); + const {stdout} = await execa('npm', arguments_); return stdout; } catch (error) { // Ignore non-existing package error diff --git a/source/util.js b/source/util.js index b58fa1b9..d89eeaca 100644 --- a/source/util.js +++ b/source/util.js @@ -1,7 +1,7 @@ import process from 'node:process'; import {fileURLToPath} from 'node:url'; import path from 'node:path'; -import {readPackageUp} from 'read-pkg-up'; +import {readPackageUp} from 'read-package-up'; import {parsePackage} from 'read-pkg'; import issueRegex from 'issue-regex'; import terminalLink from 'terminal-link'; diff --git a/test/npm/util/collaborators.js b/test/npm/util/collaborators.js index 93fb5c06..776f5a03 100644 --- a/test/npm/util/collaborators.js +++ b/test/npm/util/collaborators.js @@ -14,7 +14,6 @@ test('pkg.name not a string', async t => { }); const npmVersionFixtures = [ - {version: '8.0.0', accessCommand: 'npm access ls-collaborators np'}, {version: '9.0.0', accessCommand: 'npm access list collaborators np --json'}, ]; @@ -66,9 +65,7 @@ for (const {version, accessCommand} of npmVersionFixtures) { test(`npm v${version} - non-existent`, createFixture, [ npmVersionCommand, { - command: version === '8.0.0' - ? 'npm access ls-collaborators non-existent' - : 'npm access list collaborators non-existent --json', + command: 'npm access list collaborators non-existent --json', stderr: 'npm ERR! code E404\nnpm ERR! 404 Not Found', }, ], async ({t, testedModule: {collaborators}}) => { @@ -81,9 +78,7 @@ for (const {version, accessCommand} of npmVersionFixtures) { test(`npm v${version} - error`, createFixture, [ npmVersionCommand, { - command: version === '8.0.0' - ? 'npm access ls-collaborators @private/pkg' - : 'npm access list collaborators @private/pkg --json', + command: 'npm access list collaborators @private/pkg --json', stderr: 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden', }, ], async ({t, testedModule: {collaborators}}) => { diff --git a/test/npm/util/verify-recent-npm-version.js b/test/npm/util/verify-recent-npm-version.js index a5332d90..c471625c 100644 --- a/test/npm/util/verify-recent-npm-version.js +++ b/test/npm/util/verify-recent-npm-version.js @@ -6,7 +6,7 @@ const createFixture = _createFixture('../../../source/npm/util.js', import.meta. test('satisfied', createFixture, [{ command: 'npm --version', - stdout: '7.20.0', // One higher than minimum + stdout: '99.20.0', // Higher than minimum }], async ({t, testedModule: npm}) => { await t.notThrowsAsync( npm.verifyRecentNpmVersion(), @@ -15,10 +15,10 @@ test('satisfied', createFixture, [{ test('not satisfied', createFixture, [{ command: 'npm --version', - stdout: '7.18.0', // One lower than minimum + stdout: '5.18.0', // Lower than minimum }], async ({t, testedModule: npm}) => { await t.throwsAsync( npm.verifyRecentNpmVersion(), - {message: '`np` requires npm >=7.19.0'}, + {message: /`np` requires npm >=/}, ); }); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index a2f8b304..2363ff0a 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -96,7 +96,7 @@ test.serial('should fail when user is not authenticated at npm registry', create stdout: 'sindresorhus', }, { - command: 'npm access ls-collaborators test', + command: 'npm access list collaborators test', stdout: '{"sindresorhus": "read"}', }, ], async ({t, testedModule: prerequisiteTasks}) => { @@ -117,10 +117,6 @@ test.serial('should fail when user is not authenticated at external registry', c command: 'npm whoami --registry http://my.io', stdout: 'sindresorhus', }, - { - command: 'npm access ls-collaborators test --registry http://my.io', - stdout: '{"sindresorhus": "read"}', - }, { command: 'npm access list collaborators test --json --registry http://my.io', stdout: '{"sindresorhus": "read"}', diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index 97a46172..7c548cd7 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -1,7 +1,7 @@ import test from 'ava'; import sinon from 'sinon'; import {execa} from 'execa'; -import {removePackageDependencies, updatePackage} from 'write-pkg'; +import {removePackageDependencies, updatePackage} from 'write-package'; import stripAnsi from 'strip-ansi'; import {readPackage} from 'read-pkg'; import {createIntegrationTest} from '../_helpers/integration-test.js'; diff --git a/test/util/get-new-dependencies.js b/test/util/get-new-dependencies.js index 862a26ef..07f39769 100644 --- a/test/util/get-new-dependencies.js +++ b/test/util/get-new-dependencies.js @@ -1,5 +1,5 @@ import test from 'ava'; -import {updatePackage} from 'write-pkg'; +import {updatePackage} from 'write-package'; import {readPackage} from 'read-pkg'; import {_createFixture} from '../_helpers/integration-test.js'; diff --git a/test/util/get-new-files.js b/test/util/get-new-files.js index 290f774f..3b13055d 100644 --- a/test/util/get-new-files.js +++ b/test/util/get-new-files.js @@ -2,7 +2,7 @@ import path from 'node:path'; import test from 'ava'; import esmock from 'esmock'; import {execa} from 'execa'; -import {writePackage} from 'write-pkg'; +import {writePackage} from 'write-package'; import {createIntegrationTest} from '../_helpers/integration-test.js'; const createNewFilesFixture = test.macro(async (t, input, commands) => { diff --git a/test/util/validate-engine-version-satisfies.js b/test/util/validate-engine-version-satisfies.js index 6bd75602..cb9a5d21 100644 --- a/test/util/validate-engine-version-satisfies.js +++ b/test/util/validate-engine-version-satisfies.js @@ -5,20 +5,20 @@ const testEngineRanges = test.macro((t, engine, {above, below}) => { const range = npPkg.engines[engine]; t.notThrows( - () => validateEngineVersionSatisfies(engine, above), // One above minimum + () => validateEngineVersionSatisfies(engine, above), // Above minimum ); t.throws( - () => validateEngineVersionSatisfies(engine, below), // One below minimum + () => validateEngineVersionSatisfies(engine, below), // Below minimum {message: `\`np\` requires ${engine} ${range}`}, ); }); -test('node', testEngineRanges, 'node', {above: '16.7.0', below: '16.5.0'}); +test('node', testEngineRanges, 'node', {above: '99.7.0', below: '16.5.0'}); -test('npm', testEngineRanges, 'npm', {above: '7.20.0', below: '7.18.0'}); +test('npm', testEngineRanges, 'npm', {above: '99.20.0', below: '7.18.0'}); -test('git', testEngineRanges, 'git', {above: '2.12.0', below: '2.10.0'}); +test('git', testEngineRanges, 'git', {above: '99.12.0', below: '2.10.0'}); -test('yarn', testEngineRanges, 'yarn', {above: '1.8.0', below: '1.6.0'}); +test('yarn', testEngineRanges, 'yarn', {above: '99.8.0', below: '1.6.0'}); From 47d1dc2c1b657bdb84223751661dd70cfb0eaa4c Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Wed, 8 Nov 2023 00:30:16 +0900 Subject: [PATCH 067/180] Add note about supported Yarn versions (#720) --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index e5676ad9..3455b0cb 100644 --- a/readme.md +++ b/readme.md @@ -49,6 +49,7 @@ ### Why not - Monorepos are not supported. +- Yarn >= 2 is not supported. - Custom registries are not supported ([but could be with your help](https://github.com/sindresorhus/np/issues/420)). - CI is [not an ideal environment](https://github.com/sindresorhus/np/issues/619#issuecomment-994493179) for `np`. It's meant to be used locally as an interactive tool. From 74ae535b356f793a84208cee377647149eb0c53b Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Mon, 27 Nov 2023 19:20:13 +0800 Subject: [PATCH 068/180] Fix tests timing out locally due to GPG (#722) --- package.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b1728a7..91dd75d5 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,12 @@ "!test/_helpers" ], "environmentVariables": { - "FORCE_HYPERLINK": "1" + "FORCE_HYPERLINK": "1", + "HOME": "/tmp", + "GIT_AUTHOR_NAME": "test", + "GIT_COMMITTER_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_EMAIL": "test@example.com" }, "nodeArguments": [ "--loader=esmock", From d7543ffde30b60ed05eb5f7177baa8d08c131a44 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Mon, 27 Nov 2023 19:21:19 +0800 Subject: [PATCH 069/180] Improvements and fixes (#721) --- readme.md | 4 +++- source/index.js | 6 +++--- source/release-task-helper.js | 2 +- source/ui.js | 4 ++++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/readme.md b/readme.md index 3455b0cb..e9ae7eff 100644 --- a/readme.md +++ b/readme.md @@ -49,7 +49,7 @@ ### Why not - Monorepos are not supported. -- Yarn >= 2 is not supported. +- Yarn >= 2 and pnpm are not supported. - Custom registries are not supported ([but could be with your help](https://github.com/sindresorhus/np/issues/420)). - CI is [not an ideal environment](https://github.com/sindresorhus/np/issues/619#issuecomment-994493179) for `np`. It's meant to be used locally as an interactive tool. @@ -254,6 +254,8 @@ To publish [scoped packages](https://docs.npmjs.com/misc/scope#publishing-public If publishing a scoped package for the first time, `np` will prompt you to ask if you want to publish it publicly. +**Note:** When publishing a scoped package, the first ever version you publish has to be done interactively using `np`. If not, you cannot use `np` to publish future versions of the package. + ### Private Org-scoped packages To publish a [private Org-scoped package](https://docs.npmjs.com/creating-and-publishing-an-org-scoped-package#publishing-a-private-org-scoped-package), you need to set the access level to `restricted`. You can do that by adding the following to your `package.json`: diff --git a/source/index.js b/source/index.js index c4aa6c30..93a9407d 100644 --- a/source/index.js +++ b/source/index.js @@ -12,7 +12,7 @@ import logSymbols from 'log-symbols'; import prerequisiteTasks from './prerequisite-tasks.js'; import gitTasks from './git-tasks.js'; import publish, {getPackagePublishArguments} from './npm/publish.js'; -import enable2fa from './npm/enable-2fa.js'; +import enable2fa, {getEnable2faArgs} from './npm/enable-2fa.js'; import releaseTaskHelper from './release-task-helper.js'; import * as util from './util.js'; import * as git from './git-util.js'; @@ -227,9 +227,9 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { }, ...shouldEnable2FA ? [{ title: 'Enabling two-factor authentication', - skip() { + async skip() { if (options.preview) { - const args = enable2fa.getEnable2faArgs(pkg.name, options); + const args = await getEnable2faArgs(pkg.name, options); return `[Preview] Command not executed: npm ${args.join(' ')}.`; } }, diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 51f62058..ebaf2269 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -6,7 +6,7 @@ import Version from './version.js'; const releaseTaskHelper = async (options, pkg) => { const newVersion = options.releaseDraftOnly ? new Version(pkg.version) - : new Version(pkg.version).setFrom(options.version, {prereleasePrefix: await getPreReleasePrefix(options)}); + : new Version(pkg.version).setFrom(options.version.toString(), {prereleasePrefix: await getPreReleasePrefix(options)}); const tag = await getTagVersionPrefix(options) + newVersion.toString(); diff --git a/source/ui.js b/source/ui.js index 44931301..66e7984a 100644 --- a/source/ui.js +++ b/source/ui.js @@ -198,6 +198,10 @@ const ui = async (options, {pkg, rootDir}) => { } if (options.availability.isUnknown) { + if (!isScoped(pkg.name)) { + throw new Error('Unknown availability, but package is not scoped. This shouldn\'t happen'); + } + const answers = await inquirer.prompt({ confirm: { type: 'confirm', From e8c475b40cf54c04ae9dc6c8b90eda04256a720d Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 27 Nov 2023 12:22:09 +0100 Subject: [PATCH 070/180] Meta tweaks --- license | 2 +- package.json | 12 ++++++------ readme.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/license b/license index e7af2f77..fa7ceba3 100644 --- a/license +++ b/license @@ -1,6 +1,6 @@ MIT License -Copyright (c) Sindre Sorhus (sindresorhus.com) +Copyright (c) Sindre Sorhus (https://sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/package.json b/package.json index 91dd75d5..438e92de 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "hosted-git-info": "^7.0.1", "ignore-walk": "^6.0.3", "import-local": "^3.1.0", - "inquirer": "^9.2.11", - "is-installed-globally": "^0.4.0", + "inquirer": "^9.2.12", + "is-installed-globally": "^1.0.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", "issue-regex": "^4.1.0", @@ -55,14 +55,14 @@ "meow": "^12.1.1", "new-github-release-url": "^2.0.0", "npm-name": "^7.1.1", - "onetime": "^6.0.0", + "onetime": "^7.0.0", "open": "^9.1.0", "ow": "^1.1.1", "p-memoize": "^7.1.1", "p-timeout": "^6.1.2", "path-exists": "^5.0.0", "pkg-dir": "^8.0.0", - "read-pkg": "^9.0.0", + "read-pkg": "^9.0.1", "read-package-up": "^11.0.0", "rxjs": "^7.8.1", "semver": "^7.5.4", @@ -72,10 +72,10 @@ }, "devDependencies": { "@sindresorhus/is": "^6.1.0", - "@types/semver": "^7.5.4", + "@types/semver": "^7.5.6", "ava": "^5.3.1", "common-tags": "^1.8.2", - "esmock": "^2.5.9", + "esmock": "^2.6.0", "fs-extra": "^11.1.1", "map-obj": "^5.0.2", "sinon": "^17.0.1", diff --git a/readme.md b/readme.md index e9ae7eff..ed0c02a7 100644 --- a/readme.md +++ b/readme.md @@ -284,8 +284,8 @@ If you use a Continuous Integration server to publish your tagged commits, use t To publish to `gh-pages` (or any other branch that serves your static assets), install [`branchsite`](https://github.com/enriquecaballero/branchsite), an `np`-like CLI tool aimed to complement `np`, and create an [npm "post" hook](https://docs.npmjs.com/misc/scripts) that runs after `np`. -``` -$ npm install --save-dev branchsite +```sh +npm install --save-dev branchsite ``` ```json From 88883072a9f39376944bb4a0fbcc81abd5cf1c3f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 27 Nov 2023 12:26:39 +0100 Subject: [PATCH 071/180] 9.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 438e92de..a05c69af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "8.0.4", + "version": "9.0.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 0d9522ba0f2ab95809972568317a8adaf5a2bb77 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Tue, 5 Dec 2023 22:05:51 +0800 Subject: [PATCH 072/180] Add Yarn Berry support (#723) Co-authored-by: Sindre Sorhus --- readme.md | 2 +- source/cli-implementation.js | 7 ++-- source/index.js | 56 +++++++++++++++++++++---------- source/npm/handle-npm-error.js | 6 +++- source/npm/publish.js | 12 +++---- source/npm/util.js | 5 +++ source/ui.js | 20 +++++++++-- source/yarn.js | 16 +++++++++ test/npm/util/get-registry-url.js | 10 ++++++ test/util/yarn.js | 15 +++++++++ 10 files changed, 119 insertions(+), 30 deletions(-) create mode 100644 source/yarn.js create mode 100644 test/util/yarn.js diff --git a/readme.md b/readme.md index ed0c02a7..1f5930c8 100644 --- a/readme.md +++ b/readme.md @@ -49,7 +49,7 @@ ### Why not - Monorepos are not supported. -- Yarn >= 2 and pnpm are not supported. +- pnpm is not supported. - Custom registries are not supported ([but could be with your help](https://github.com/sindresorhus/np/issues/420)). - CI is [not an ideal environment](https://github.com/sindresorhus/np/issues/619#issuecomment-994493179) for `np`. It's meant to be used locally as an interactive tool. diff --git a/source/cli-implementation.js b/source/cli-implementation.js index e706f2ad..9f7ffe45 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -12,6 +12,7 @@ import * as git from './git-util.js'; import * as npm from './npm/util.js'; import {SEMVER_INCREMENTS} from './version.js'; import ui from './ui.js'; +import {checkIfYarnBerry} from './yarn.js'; import np from './index.js'; const cli = meow(` @@ -131,20 +132,22 @@ try { const branch = flags.branch ?? await git.defaultBranch(); + const isYarnBerry = flags.yarn && checkIfYarnBerry(pkg); + const options = await ui({ ...flags, runPublish, availability, version, branch, - }, {pkg, rootDir}); + }, {pkg, rootDir, isYarnBerry}); if (!options.confirm) { gracefulExit(); } console.log(); // Prints a newline for readability - const newPkg = await np(options.version, options, {pkg, rootDir}); + const newPkg = await np(options.version, options, {pkg, rootDir, isYarnBerry}); if (options.preview || options.releaseDraftOnly) { gracefulExit(); diff --git a/source/index.js b/source/index.js index 93a9407d..94ed1550 100644 --- a/source/index.js +++ b/source/index.js @@ -18,15 +18,15 @@ import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -const exec = (cmd, args) => { +const exec = (cmd, args, options) => { // Use `Observable` support if merged https://github.com/sindresorhus/execa/pull/26 - const cp = execa(cmd, args); + const cp = execa(cmd, args, options); return merge(cp.stdout, cp.stderr, cp).pipe(filter(Boolean)); }; // eslint-disable-next-line complexity -const np = async (input = 'patch', options, {pkg, rootDir}) => { +const np = async (input = 'patch', options, {pkg, rootDir, isYarnBerry}) => { if (!hasYarn() && options.yarn) { throw new Error('Could not use Yarn without yarn.lock file'); } @@ -36,10 +36,22 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { options.cleanup = false; } + function getPackageManagerName() { + if (options.yarn === true) { + if (isYarnBerry) { + return 'Yarn Berry'; + } + + return 'Yarn'; + } + + return 'npm'; + } + const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; const pkgManager = options.yarn === true ? 'yarn' : 'npm'; - const pkgManagerName = options.yarn === true ? 'Yarn' : 'npm'; + const pkgManagerName = getPackageManagerName(); const hasLockFile = fs.existsSync(path.resolve(rootDir, options.yarn ? 'yarn.lock' : 'package-lock.json')) || fs.existsSync(path.resolve(rootDir, 'npm-shrinkwrap.json')); const isOnGitHub = options.repoUrl && hostedGitInfo.fromUrl(options.repoUrl)?.type === 'github'; const testScript = options.testScript || 'test'; @@ -88,6 +100,13 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !npm.isExternalRegistry(pkg); + // Yarn berry doesn't support git commiting/tagging, so use npm + const shouldUseYarnForVersioning = options.yarn === true && !isYarnBerry; + const shouldUseNpmForVersioning = options.yarn === false || isYarnBerry; + + // To prevent the process from hanging due to watch mode (e.g. when running `vitest`) + const ciEnvOptions = {env: {CI: 'true'}}; + const tasks = new Listr([ { title: 'Prerequisite check', @@ -105,10 +124,11 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { task: () => deleteAsync('node_modules'), }, { - title: 'Installing dependencies using Yarn', + title: `Installing dependencies using ${pkgManagerName}`, enabled: () => options.yarn === true, - task: () => ( - exec('yarn', ['install', '--frozen-lockfile', '--production=false']).pipe( + task() { + const args = isYarnBerry ? ['install', '--immutable'] : ['install', '--frozen-lockfile', '--production=false']; + return exec('yarn', args).pipe( catchError(async error => { if ((!error.stderr.startsWith('error Your lockfile needs to be updated'))) { return; @@ -120,8 +140,8 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { throw new Error('yarn.lock file is outdated. Run yarn, commit the updated lockfile and try again.'); }), - ) - ), + ); + }, }, { title: 'Installing dependencies using npm', @@ -134,14 +154,14 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { ] : [], ...runTests ? [ { - title: 'Running tests using npm', + title: `Running tests using ${pkgManagerName}`, enabled: () => options.yarn === false, - task: () => exec('npm', testCommand), + task: () => exec('npm', testCommand, ciEnvOptions), }, { - title: 'Running tests using Yarn', + title: `Running tests using ${pkgManagerName}`, enabled: () => options.yarn === true, - task: () => exec('yarn', testCommand).pipe( + task: () => exec('yarn', testCommand, ciEnvOptions).pipe( catchError(error => { if (error.message.includes(`Command "${testScript}" not found`)) { return []; @@ -153,8 +173,8 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { }, ] : [], { - title: 'Bumping version using Yarn', - enabled: () => options.yarn === true, + title: `Bumping version using ${pkgManagerName}`, + enabled: () => shouldUseYarnForVersioning, skip() { if (options.preview) { let previewText = `[Preview] Command not executed: yarn version --new-version ${input}`; @@ -178,7 +198,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { }, { title: 'Bumping version using npm', - enabled: () => options.yarn === false, + enabled: () => shouldUseNpmForVersioning, skip() { if (options.preview) { let previewText = `[Preview] Command not executed: npm version ${input}`; @@ -205,14 +225,14 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { title: `Publishing package using ${pkgManagerName}`, skip() { if (options.preview) { - const args = getPackagePublishArguments(options); + const args = getPackagePublishArguments(options, isYarnBerry); return `[Preview] Command not executed: ${pkgManager} ${args.join(' ')}.`; } }, task(context, task) { let hasError = false; - return publish(context, pkgManager, task, options) + return publish(context, pkgManager, isYarnBerry, task, options) .pipe( catchError(async error => { hasError = true; diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index 4188ea54..9ad57ba7 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -26,7 +26,11 @@ const handleNpmError = (error, task, message, executor) => { // Attempting to privately publish a scoped package without the correct npm plan // https://stackoverflow.com/a/44862841/10292952 - if (error.code === 402 || error.stderr.includes('npm ERR! 402 Payment Required')) { + if ( + error.code === 402 + || error.stderr.includes('npm ERR! 402 Payment Required') // Npm + || error.stdout.includes('Response Code: 402 (Payment Required)') // Yarn Berry + ) { throw new Error('You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'); } diff --git a/source/npm/publish.js b/source/npm/publish.js index 76d09290..a35a2d5a 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -2,8 +2,8 @@ import {execa} from 'execa'; import {from, catchError} from 'rxjs'; import handleNpmError from './handle-npm-error.js'; -export const getPackagePublishArguments = options => { - const args = ['publish']; +export const getPackagePublishArguments = (options, isYarnBerry) => { + const args = isYarnBerry ? ['npm', 'publish'] : ['publish']; if (options.contents) { args.push(options.contents); @@ -24,14 +24,14 @@ export const getPackagePublishArguments = options => { return args; }; -const pkgPublish = (pkgManager, options) => execa(pkgManager, getPackagePublishArguments(options)); +const pkgPublish = (pkgManager, isYarnBerry, options) => execa(pkgManager, getPackagePublishArguments(options, isYarnBerry)); -const publish = (context, pkgManager, task, options) => - from(pkgPublish(pkgManager, options)).pipe( +const publish = (context, pkgManager, isYarnBerry, task, options) => + from(pkgPublish(pkgManager, isYarnBerry, options)).pipe( catchError(error => handleNpmError(error, task, otp => { context.otp = otp; - return pkgPublish(pkgManager, {...options, otp}); + return pkgPublish(pkgManager, isYarnBerry, {...options, otp}); })), ); diff --git a/source/npm/util.js b/source/npm/util.js index 4954cbf4..aa4bccbd 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -146,6 +146,11 @@ export const getFilesToBePacked = async rootDir => { }; export const getRegistryUrl = async (pkgManager, pkg) => { + if (pkgManager === 'yarn-berry') { + const {stdout} = await execa('yarn', ['config', 'get', 'npmRegistryServer']); + return stdout; + } + const args = ['config', 'get', 'registry']; if (isExternalRegistry(pkg)) { args.push('--registry', pkg.publishConfig.registry); diff --git a/source/ui.js b/source/ui.js index 66e7984a..f03f4f2b 100644 --- a/source/ui.js +++ b/source/ui.js @@ -120,11 +120,27 @@ const checkNewFilesAndDependencies = async (pkg, rootDir) => { }; // eslint-disable-next-line complexity -const ui = async (options, {pkg, rootDir}) => { +const ui = async (options, {pkg, rootDir, isYarnBerry}) => { const oldVersion = pkg.version; const extraBaseUrls = ['gitlab.com']; const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); - const pkgManager = options.yarn ? 'yarn' : 'npm'; + + const pkgManager = (() => { + if (!options.yarn) { + return 'npm'; + } + + if (isYarnBerry) { + return 'yarn-berry'; + } + + return 'yarn'; + })(); + + if (isYarnBerry && npm.isExternalRegistry(pkg)) { + throw new Error('External registry is not yet supported with Yarn Berry'); + } + const registryUrl = await npm.getRegistryUrl(pkgManager, pkg); const releaseBranch = options.branch; diff --git a/source/yarn.js b/source/yarn.js new file mode 100644 index 00000000..d4da4275 --- /dev/null +++ b/source/yarn.js @@ -0,0 +1,16 @@ +import semver from 'semver'; + +export function checkIfYarnBerry(pkg) { + if (typeof pkg.packageManager !== 'string') { + return false; + } + + const match = pkg.packageManager.match(/^yarn@(.+)$/); + if (!match) { + return false; + } + + const [, yarnVersion] = match; + const versionParsed = semver.parse(yarnVersion); + return (versionParsed.major >= 2); +} diff --git a/test/npm/util/get-registry-url.js b/test/npm/util/get-registry-url.js index fdfd47ea..318804da 100644 --- a/test/npm/util/get-registry-url.js +++ b/test/npm/util/get-registry-url.js @@ -24,6 +24,16 @@ test('yarn', createFixture, [{ ); }); +test('yarn-berry', createFixture, [{ + command: 'yarn config get npmRegistryServer', + stdout: 'https://registry.yarnpkg.com', +}], async ({t, testedModule: npm}) => { + t.is( + await npm.getRegistryUrl('yarn-berry', {}), + 'https://registry.yarnpkg.com', + ); +}); + test('external', createFixture, [{ command: 'npm config get registry --registry http://my-internal-registry.local', stdout: 'http://my-internal-registry.local', diff --git a/test/util/yarn.js b/test/util/yarn.js new file mode 100644 index 00000000..bae4fd36 --- /dev/null +++ b/test/util/yarn.js @@ -0,0 +1,15 @@ +import test from 'ava'; +import {checkIfYarnBerry} from '../../source/yarn.js'; + +test('checkIfYarnBerry', t => { + t.is(checkIfYarnBerry({}), false); + t.is(checkIfYarnBerry({ + packageManager: 'npm', + }), false); + t.is(checkIfYarnBerry({ + packageManager: 'yarn@1.0.0', + }), false); + t.is(checkIfYarnBerry({ + packageManager: 'yarn@2.0.0', + }), true); +}); From d49dd4fee6521c37f14e4d23c9bcb91df9293a24 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 5 Dec 2023 15:10:19 +0100 Subject: [PATCH 073/180] 9.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a05c69af..a2a4513b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "9.0.0", + "version": "9.1.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 819ed291e719e46c4743a9ea76d070c60d8cce5b Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 9 Dec 2023 14:09:02 +0800 Subject: [PATCH 074/180] Fix broken revert code after publish failure --- source/index.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/source/index.js b/source/index.js index 94ed1550..338dbaf3 100644 --- a/source/index.js +++ b/source/index.js @@ -73,9 +73,14 @@ const np = async (input = 'patch', options, {pkg, rootDir, isYarnBerry}) => { const latestTag = await git.latestTag(); const versionInLatestTag = latestTag.slice(tagVersionPrefix.length); + async function getPkgVersion() { + const pkg = await util.readPkg(rootDir); + return pkg.version; + } + try { // Verify that the package's version has been bumped before deleting the last tag and commit. - if (versionInLatestTag === util.readPkg(rootDir).version && versionInLatestTag !== pkg.version) { + if (versionInLatestTag === await getPkgVersion() && versionInLatestTag !== pkg.version) { await git.deleteTag(latestTag); await git.removeLastCommit(); } From 777148d5dfdcb552d30256be8e94abafbf85480b Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 9 Dec 2023 14:13:55 +0800 Subject: [PATCH 075/180] Make boolean logic easier to understand --- source/ui.js | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/source/ui.js b/source/ui.js index f03f4f2b..06e14632 100644 --- a/source/ui.js +++ b/source/ui.js @@ -235,8 +235,27 @@ const ui = async (options, {pkg, rootDir, isYarnBerry}) => { } } - const needsPrereleaseTag = answers => options.runPublish && (answers.version?.isPrerelease() || answers.customVersion?.isPrerelease()) && !options.tag; - const canBePublishedPublicly = options.availability.isAvailable && !options.availability.isUnknown && options.runPublish && (pkg.publishConfig && pkg.publishConfig.access !== 'restricted') && !npm.isExternalRegistry(pkg); + const needsPrereleaseTag = answers => ( + options.runPublish + && (answers.version?.isPrerelease() || answers.customVersion?.isPrerelease()) + && !options.tag + ); + + // 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 (!isScoped(pkg.name) || !options.availability.isAvailable || options.availability.isUnknown || !options.runPublish) { + return false; + } + + if (!pkg.publishConfig) { + return true; + } + + return pkg.publishConfig.access !== 'restricted' && !npm.isExternalRegistry(pkg); + })(); const answers = await inquirer.prompt({ version: { @@ -318,7 +337,7 @@ const ui = async (options, {pkg, rootDir, isYarnBerry}) => { }, publishScoped: { type: 'confirm', - when: isScoped(pkg.name) && canBePublishedPublicly, + when: needToAskForPublish, message: `This scoped repo ${chalk.bold.magenta(pkg.name)} hasn't been published. Do you want to publish it publicly?`, default: false, }, From 8d3a984a0f94b43ac2843d62fc7b44ce5b0fffee Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 9 Dec 2023 14:17:01 +0800 Subject: [PATCH 076/180] Fix yarn npm publish for scoped packages --- source/ui.js | 8 +++++--- source/util.js | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/source/ui.js b/source/ui.js index 06e14632..3fb1a607 100644 --- a/source/ui.js +++ b/source/ui.js @@ -120,7 +120,7 @@ const checkNewFilesAndDependencies = async (pkg, rootDir) => { }; // eslint-disable-next-line complexity -const ui = async (options, {pkg, rootDir, isYarnBerry}) => { +const ui = async (options, {pkg, rootDir, isYarnBerry = false}) => { const oldVersion = pkg.version; const extraBaseUrls = ['gitlab.com']; const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); @@ -241,12 +241,14 @@ const ui = async (options, {pkg, rootDir, isYarnBerry}) => { && !options.tag ); + const alreadyPublicScoped = isYarnBerry && options.runPublish && await util.getNpmPackageAccess(pkg.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 (!isScoped(pkg.name) || !options.availability.isAvailable || options.availability.isUnknown || !options.runPublish) { + if (alreadyPublicScoped || !isScoped(pkg.name) || !options.availability.isAvailable || options.availability.isUnknown || !options.runPublish) { return false; } @@ -347,7 +349,7 @@ const ui = async (options, {pkg, rootDir, isYarnBerry}) => { ...options, version: answers.version || answers.customVersion || options.version, tag: answers.tag || answers.customTag || options.tag, - publishScoped: answers.publishScoped, + publishScoped: alreadyPublicScoped || answers.publishScoped, confirm: true, repoUrl, releaseNotes, diff --git a/source/util.js b/source/util.js index d89eeaca..379aba31 100644 --- a/source/util.js +++ b/source/util.js @@ -150,3 +150,8 @@ export const validateEngineVersionSatisfies = (engine, version) => { throw new Error(`\`np\` requires ${engine} ${engineRange}`); } }; + +export async function getNpmPackageAccess(name) { + const {stdout} = await execa('npm', ['access', 'get', 'status', name, '--json']); + return JSON.parse(stdout)[name]; // Note: returns "private" for non-existent packages +} From 37d4bc9a71b04019df22f6263cc03e23283a558f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 9 Dec 2023 14:44:41 +0100 Subject: [PATCH 077/180] 9.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a2a4513b..bab6ae23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "9.1.0", + "version": "9.2.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 4b3b599571c9475b2be035b370fef4fe0b2e85d0 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Mon, 26 Feb 2024 07:55:38 -0500 Subject: [PATCH 078/180] Add pnpm support (#730) Co-authored-by: Misha Kaletsky Co-authored-by: Sindre Sorhus --- package.json | 8 +- readme.md | 22 ++- source/.npmignore | 1 + source/cli-implementation.js | 31 ++-- source/git-util.js | 15 -- source/index.js | 185 ++++++++------------- source/npm/handle-npm-error.js | 2 +- source/npm/publish.js | 21 +-- source/npm/util.js | 15 -- source/package-manager/configs.js | 51 ++++++ source/package-manager/index.js | 60 +++++++ source/package-manager/types.d.ts | 58 +++++++ source/prerequisite-tasks.js | 15 +- source/release-task-helper.js | 6 +- source/ui.js | 31 ++-- source/util.js | 17 +- source/yarn.js | 16 -- test/cli.js | 2 +- test/git-util/check-if-file-git-ignored.js | 20 --- test/index.js | 11 +- test/npm/util/get-registry-url.js | 49 ------ test/tasks/prerequisite-tasks.js | 35 ++-- test/ui/new-files-dependencies.js | 1 - test/ui/prompts/tags.js | 4 +- test/ui/prompts/version.js | 1 - test/util/get-pre-release-prefix.js | 14 +- test/util/get-tag-version-prefix.js | 11 +- test/util/yarn.js | 15 -- 28 files changed, 348 insertions(+), 369 deletions(-) create mode 100644 source/.npmignore create mode 100644 source/package-manager/configs.js create mode 100644 source/package-manager/index.js create mode 100644 source/package-manager/types.d.ts delete mode 100644 source/yarn.js delete mode 100644 test/git-util/check-if-file-git-ignored.js delete mode 100644 test/npm/util/get-registry-url.js delete mode 100644 test/util/yarn.js diff --git a/package.json b/package.json index bab6ae23..4d266119 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "node": ">=18", "npm": ">=9", "git": ">=2.11.0", - "yarn": ">=1.7.0" + "yarn": ">=1.7.0", + "pnpm": ">=8" }, "scripts": { "test": "xo && ava" @@ -40,7 +41,6 @@ "execa": "^8.0.1", "exit-hook": "^4.0.0", "github-url-from-git": "^1.5.0", - "has-yarn": "^3.0.0", "hosted-git-info": "^7.0.1", "ignore-walk": "^6.0.3", "import-local": "^3.1.0", @@ -52,7 +52,7 @@ "listr": "^0.14.3", "listr-input": "^0.2.1", "log-symbols": "^6.0.0", - "meow": "^12.1.1", + "meow": "^13.1.0", "new-github-release-url": "^2.0.0", "npm-name": "^7.1.1", "onetime": "^7.0.0", @@ -62,8 +62,8 @@ "p-timeout": "^6.1.2", "path-exists": "^5.0.0", "pkg-dir": "^8.0.0", - "read-pkg": "^9.0.1", "read-package-up": "^11.0.0", + "read-pkg": "^9.0.1", "rxjs": "^7.8.1", "semver": "^7.5.4", "symbol-observable": "^4.0.0", diff --git a/readme.md b/readme.md index 1f5930c8..84699c54 100644 --- a/readme.md +++ b/readme.md @@ -74,7 +74,7 @@ $ np --help $ np Version can be: - major | minor | patch | premajor | preminor | prepatch | prerelease | 1.2.3 + patch | minor | major | prepatch | preminor | premajor | prerelease | 1.2.3 Options --any-branch Allow publishing from any branch @@ -85,13 +85,13 @@ $ np --help --no-publish Skips publishing --preview Show tasks without actually executing them --tag Publish under a given dist-tag - --no-yarn Don't use Yarn --contents Subdirectory to publish --no-release-draft Skips opening a GitHub release draft --release-draft-only Only opens a GitHub release draft for the latest published version --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) - --message Version bump commit message. `%s` will be replaced with version. (default: '%s' with npm and 'v%s' with yarn) + --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) + --package-manager Use a specific package manager (default: 'packageManager' field in package.json) Examples $ np @@ -121,21 +121,21 @@ Currently, these are the flags you can configure: - `publish` - Publish (`true` by default). - `preview` - Show tasks without actually executing them (`false` by default). - `tag` - Publish under a given dist-tag (`latest` by default). -- `yarn` - Use yarn if possible (`true` by default). - `contents` - Subdirectory to publish (`.` by default). - `releaseDraft` - Open a GitHub release draft after releasing (`true` by default). - `testScript` - Name of npm run script to run tests before publishing (`test` by default). - `2fa` - Enable 2FA on new packages (`true` by default) (setting this to `false` is not recommended). - `message` - The commit message used for the version bump. Any `%s` in the string will be replaced with the new version. By default, npm uses `%s` and Yarn uses `v%s`. +- `packageManager` - Set the package manager to be used. Defaults to the [packageManager field in package.json](https://nodejs.org/api/packages.html#packagemanager), so only use if you can't update package.json for some reason. -For example, this configures `np` to never use Yarn and to use `dist` as the subdirectory to publish: +For example, this configures `np` to use `unit-test` as a test script, and to use `dist` as the subdirectory to publish: `package.json` ```json { "name": "superb-package", "np": { - "yarn": false, + "testScript": "unit-test", "contents": "dist" } } @@ -144,7 +144,7 @@ For example, this configures `np` to never use Yarn and to use `dist` as the sub `.np-config.json` ```json { - "yarn": false, + "testScript": "unit-test", "contents": "dist" } ``` @@ -152,7 +152,7 @@ For example, this configures `np` to never use Yarn and to use `dist` as the sub `.np-config.js` or `.np-config.cjs` ```js module.exports = { - yarn: false, + testScript: 'unit-test', contents: 'dist' }; ``` @@ -160,7 +160,7 @@ module.exports = { `.np-config.mjs` ```js export default { - yarn: false, + testScript: 'unit-test', contents: 'dist' }; ``` @@ -276,6 +276,10 @@ Set the [`registry` option](https://docs.npmjs.com/misc/config#registry) in pack } ``` +### Package managers + +If a package manager is not set in package.json, via configuration (`packageManager`), or via the CLI (`--package-manager`), `np` will attempt to infer the best package manager to use by looking for lockfiles. But it's recommended to set the [`packageManager` field](https://nodejs.org/api/packages.html#packagemanager) in your package.json to be consistent with other tools. See also the [corepack docs](https://nodejs.org/api/corepack.html). + ### Publish with a CI If you use a Continuous Integration server to publish your tagged commits, use the `--no-publish` flag to skip the publishing step of `np`. diff --git a/source/.npmignore b/source/.npmignore new file mode 100644 index 00000000..cd4efd8e --- /dev/null +++ b/source/.npmignore @@ -0,0 +1 @@ +*.d.ts diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 9f7ffe45..c13fa608 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -4,7 +4,6 @@ import 'symbol-observable'; // Important: This needs to be first to prevent weir import logSymbols from 'log-symbols'; import meow from 'meow'; import updateNotifier from 'update-notifier'; -import hasYarn from 'has-yarn'; import {gracefulExit} from 'exit-hook'; import config from './config.js'; import * as util from './util.js'; @@ -12,9 +11,10 @@ import * as git from './git-util.js'; import * as npm from './npm/util.js'; import {SEMVER_INCREMENTS} from './version.js'; import ui from './ui.js'; -import {checkIfYarnBerry} from './yarn.js'; import np from './index.js'; +/** @typedef {typeof cli} CLI */ + const cli = meow(` Usage $ np @@ -31,13 +31,13 @@ const cli = meow(` --no-publish Skips publishing --preview Show tasks without actually executing them --tag Publish under a given dist-tag - --no-yarn Don't use Yarn --contents Subdirectory to publish --no-release-draft Skips opening a GitHub release draft --release-draft-only Only opens a GitHub release draft for the latest published version --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) + --package-manager Use a specific package manager (default: 'packageManager' field in package.json) Examples $ np @@ -80,9 +80,8 @@ const cli = meow(` tag: { type: 'string', }, - yarn: { - type: 'boolean', - default: hasYarn(), + packageManager: { + type: 'string', }, contents: { type: 'string', @@ -105,7 +104,9 @@ const cli = meow(` updateNotifier({pkg: cli.pkg}).notify(); -try { +/** @typedef {Awaited>['options']} Options */ + +export async function getOptions() { const {pkg, rootDir} = await util.readPkg(cli.flags.contents); const localConfig = await config(rootDir); @@ -119,6 +120,10 @@ try { flags['2fa'] = flags['2Fa']; } + if (flags.packageManager) { + pkg.packageManager = flags.packageManager; + } + const runPublish = !flags.releaseDraftOnly && flags.publish && !pkg.private; // TODO: does this need to run if `runPublish` is false? @@ -132,22 +137,26 @@ try { const branch = flags.branch ?? await git.defaultBranch(); - const isYarnBerry = flags.yarn && checkIfYarnBerry(pkg); - const options = await ui({ ...flags, runPublish, availability, version, branch, - }, {pkg, rootDir, isYarnBerry}); + }, {pkg, rootDir}); + + return {options, rootDir, pkg}; +} + +try { + const {options, rootDir, pkg} = await getOptions(); if (!options.confirm) { gracefulExit(); } console.log(); // Prints a newline for readability - const newPkg = await np(options.version, options, {pkg, rootDir, isYarnBerry}); + const newPkg = await np(options.version, options, {pkg, rootDir}); if (options.preview || options.releaseDraftOnly) { gracefulExit(); diff --git a/source/git-util.js b/source/git-util.js index cfe36356..037d4a27 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -270,18 +270,3 @@ export const verifyRecentGitVersion = async () => { const installedVersion = await gitVersion(); util.validateEngineVersionSatisfies('git', installedVersion); }; - -export const checkIfFileGitIgnored = async pathToFile => { - try { - const {stdout} = await execa('git', ['check-ignore', pathToFile]); - return Boolean(stdout); - } catch (error) { - // If file is not ignored, `git check-ignore` throws an empty error and exits. - // Check that and return false so as not to throw an unwanted error. - if (error.stdout === '' && error.stderr === '') { - return false; - } - - throw error; - } -}; diff --git a/source/index.js b/source/index.js index 338dbaf3..f5484dba 100644 --- a/source/index.js +++ b/source/index.js @@ -1,23 +1,23 @@ -import fs from 'node:fs'; -import path from 'node:path'; import {execa} from 'execa'; import {deleteAsync} from 'del'; import Listr from 'listr'; -import {merge, throwError, catchError, filter, finalize} from 'rxjs'; -import hasYarn from 'has-yarn'; +import {merge, catchError, filter, finalize, from} from 'rxjs'; import hostedGitInfo from 'hosted-git-info'; import onetime from 'onetime'; import {asyncExitHook} from 'exit-hook'; import logSymbols from 'log-symbols'; import prerequisiteTasks from './prerequisite-tasks.js'; import gitTasks from './git-tasks.js'; -import publish, {getPackagePublishArguments} from './npm/publish.js'; +import {getPackagePublishArguments} from './npm/publish.js'; import enable2fa, {getEnable2faArgs} from './npm/enable-2fa.js'; +import handleNpmError from './npm/handle-npm-error.js'; import releaseTaskHelper from './release-task-helper.js'; +import {findLockfile, getPackageManagerConfig, printCommand} from './package-manager/index.js'; import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; +/** @type {(cmd: string, args: string[], options?: import('execa').Options) => any} */ const exec = (cmd, args, options) => { // Use `Observable` support if merged https://github.com/sindresorhus/execa/pull/26 const cp = execa(cmd, args, options); @@ -25,40 +25,27 @@ const exec = (cmd, args, options) => { return merge(cp.stdout, cp.stderr, cp).pipe(filter(Boolean)); }; -// eslint-disable-next-line complexity -const np = async (input = 'patch', options, {pkg, rootDir, isYarnBerry}) => { - if (!hasYarn() && options.yarn) { - throw new Error('Could not use Yarn without yarn.lock file'); - } +/** +@param {string} input +@param {import('./cli-implementation.js').Options} options +@param {{pkg: import('read-pkg').NormalizedPackageJson; rootDir: string}} context +*/ +const np = async (input = 'patch', options, {pkg, rootDir}) => { + const pkgManager = getPackageManagerConfig(rootDir, pkg); // TODO: Remove sometime far in the future if (options.skipCleanup) { options.cleanup = false; } - function getPackageManagerName() { - if (options.yarn === true) { - if (isYarnBerry) { - return 'Yarn Berry'; - } - - return 'Yarn'; - } - - return 'npm'; - } - const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; - const pkgManager = options.yarn === true ? 'yarn' : 'npm'; - const pkgManagerName = getPackageManagerName(); - const hasLockFile = fs.existsSync(path.resolve(rootDir, options.yarn ? 'yarn.lock' : 'package-lock.json')) || fs.existsSync(path.resolve(rootDir, 'npm-shrinkwrap.json')); + const lockfile = findLockfile(rootDir, pkgManager); const isOnGitHub = options.repoUrl && hostedGitInfo.fromUrl(options.repoUrl)?.type === 'github'; const testScript = options.testScript || 'test'; - const testCommand = options.testScript ? ['run', testScript] : [testScript]; if (options.releaseDraftOnly) { - await releaseTaskHelper(options, pkg); + await releaseTaskHelper(options, pkg, pkgManager); return pkg; } @@ -68,7 +55,7 @@ const np = async (input = 'patch', options, {pkg, rootDir, isYarnBerry}) => { const rollback = onetime(async () => { console.log('\nPublish failed. Rolling back to the previous state…'); - const tagVersionPrefix = await util.getTagVersionPrefix(options); + const tagVersionPrefix = await util.getTagVersionPrefix(pkgManager); const latestTag = await git.latestTag(); const versionInLatestTag = latestTag.slice(tagVersionPrefix.length); @@ -105,139 +92,97 @@ const np = async (input = 'patch', options, {pkg, rootDir, isYarnBerry}) => { const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !npm.isExternalRegistry(pkg); - // Yarn berry doesn't support git commiting/tagging, so use npm - const shouldUseYarnForVersioning = options.yarn === true && !isYarnBerry; - const shouldUseNpmForVersioning = options.yarn === false || isYarnBerry; - // To prevent the process from hanging due to watch mode (e.g. when running `vitest`) const ciEnvOptions = {env: {CI: 'true'}}; + /** @param {typeof options} _options */ + function getPublishCommand(_options) { + const publishCommand = pkgManager.publishCommand || (args => [pkgManager.cli, args]); + const args = getPackagePublishArguments(_options); + return publishCommand(args); + } + const tasks = new Listr([ { title: 'Prerequisite check', enabled: () => options.runPublish, - task: () => prerequisiteTasks(input, pkg, options), + task: () => prerequisiteTasks(input, pkg, options, pkgManager), }, { title: 'Git', task: () => gitTasks(options), }, - ...runCleanup ? [ - { - title: 'Cleanup', - enabled: () => !hasLockFile, - task: () => deleteAsync('node_modules'), - }, - { - title: `Installing dependencies using ${pkgManagerName}`, - enabled: () => options.yarn === true, - task() { - const args = isYarnBerry ? ['install', '--immutable'] : ['install', '--frozen-lockfile', '--production=false']; - return exec('yarn', args).pipe( - catchError(async error => { - if ((!error.stderr.startsWith('error Your lockfile needs to be updated'))) { - return; - } - - if (await git.checkIfFileGitIgnored('yarn.lock')) { - return; - } - - throw new Error('yarn.lock file is outdated. Run yarn, commit the updated lockfile and try again.'); - }), - ); + { + title: 'Cleanup', + enabled: () => runCleanup && !lockfile, + task: () => deleteAsync('node_modules'), + }, + { + title: `Installing dependencies using ${pkgManager.id}`, + enabled: () => runCleanup, + task: () => new Listr([ + { + title: 'Running install command', + task() { + const installCommand = lockfile ? pkgManager.installCommand : pkgManager.installCommandNoLockfile; + return exec(...installCommand); + }, }, - }, - { - title: 'Installing dependencies using npm', - enabled: () => options.yarn === false, - task() { - const args = hasLockFile ? ['ci'] : ['install', '--no-package-lock', '--no-production']; - return exec('npm', [...args, '--engine-strict']); + { + title: 'Checking working tree is still clean', // If lockfile was out of date and tracked by git, this will fail + task: () => git.verifyWorkingTreeIsClean(), }, - }, - ] : [], - ...runTests ? [ - { - title: `Running tests using ${pkgManagerName}`, - enabled: () => options.yarn === false, - task: () => exec('npm', testCommand, ciEnvOptions), - }, - { - title: `Running tests using ${pkgManagerName}`, - enabled: () => options.yarn === true, - task: () => exec('yarn', testCommand, ciEnvOptions).pipe( - catchError(error => { - if (error.message.includes(`Command "${testScript}" not found`)) { - return []; - } - - return throwError(() => error); - }), - ), - }, - ] : [], + ]), + }, { - title: `Bumping version using ${pkgManagerName}`, - enabled: () => shouldUseYarnForVersioning, - skip() { - if (options.preview) { - let previewText = `[Preview] Command not executed: yarn version --new-version ${input}`; - - if (options.message) { - previewText += ` --message '${options.message.replaceAll('%s', input)}'`; - } - - return `${previewText}.`; - } - }, - task() { - const args = ['version', '--new-version', input]; - - if (options.message) { - args.push('--message', options.message); - } - - return exec('yarn', args); - }, + title: 'Running tests', + enabled: () => runTests, + task: () => exec(pkgManager.cli, ['run', testScript], ciEnvOptions), }, { - title: 'Bumping version using npm', - enabled: () => shouldUseNpmForVersioning, + title: 'Bumping version', skip() { if (options.preview) { - let previewText = `[Preview] Command not executed: npm version ${input}`; + const [cli, args] = pkgManager.versionCommand(input); if (options.message) { - previewText += ` --message '${options.message.replaceAll('%s', input)}'`; + args.push('--message', options.message.replaceAll('%s', input)); } - return `${previewText}.`; + return `[Preview] Command not executed: ${printCommand([cli, args])}`; } }, task() { - const args = ['version', input]; + const [cli, args] = pkgManager.versionCommand(input); if (options.message) { args.push('--message', options.message); } - return exec('npm', args); + return exec(cli, args); }, }, ...options.runPublish ? [ { - title: `Publishing package using ${pkgManagerName}`, + title: 'Publishing package', skip() { if (options.preview) { - const args = getPackagePublishArguments(options, isYarnBerry); - return `[Preview] Command not executed: ${pkgManager} ${args.join(' ')}.`; + const command = getPublishCommand(options); + return `[Preview] Command not executed: ${printCommand(command)}.`; } }, + /** @type {(context, task) => Listr.ListrTaskResult} */ task(context, task) { let hasError = false; - return publish(context, pkgManager, isYarnBerry, task, options) + return from(execa(...getPublishCommand(options))) + .pipe( + catchError(error => handleNpmError(error, task, otp => { + context.otp = otp; + + return execa(...getPublishCommand({...options, otp})); + })), + ) .pipe( catchError(async error => { hasError = true; @@ -289,7 +234,7 @@ const np = async (input = 'patch', options, {pkg, rootDir, isYarnBerry}) => { } }, // TODO: parse version outside of index - task: () => releaseTaskHelper(options, pkg), + task: () => releaseTaskHelper(options, pkg, pkgManager), }] : [], ], { showSubtasks: false, diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index 9ad57ba7..9709e2cb 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -28,7 +28,7 @@ const handleNpmError = (error, task, message, executor) => { // https://stackoverflow.com/a/44862841/10292952 if ( error.code === 402 - || error.stderr.includes('npm ERR! 402 Payment Required') // Npm + || error.stderr.includes('npm ERR! 402 Payment Required') // Npm/pnpm || error.stdout.includes('Response Code: 402 (Payment Required)') // Yarn Berry ) { throw new Error('You cannot publish a scoped package privately without a paid plan. Did you mean to publish publicly?'); diff --git a/source/npm/publish.js b/source/npm/publish.js index a35a2d5a..8a499239 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -1,9 +1,5 @@ -import {execa} from 'execa'; -import {from, catchError} from 'rxjs'; -import handleNpmError from './handle-npm-error.js'; - -export const getPackagePublishArguments = (options, isYarnBerry) => { - const args = isYarnBerry ? ['npm', 'publish'] : ['publish']; +export const getPackagePublishArguments = options => { + const args = ['publish']; if (options.contents) { args.push(options.contents); @@ -23,16 +19,3 @@ export const getPackagePublishArguments = (options, isYarnBerry) => { return args; }; - -const pkgPublish = (pkgManager, isYarnBerry, options) => execa(pkgManager, getPackagePublishArguments(options, isYarnBerry)); - -const publish = (context, pkgManager, isYarnBerry, task, options) => - from(pkgPublish(pkgManager, isYarnBerry, options)).pipe( - catchError(error => handleNpmError(error, task, otp => { - context.otp = otp; - - return pkgPublish(pkgManager, isYarnBerry, {...options, otp}); - })), - ); - -export default publish; diff --git a/source/npm/util.js b/source/npm/util.js index aa4bccbd..a3250832 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -144,18 +144,3 @@ export const getFilesToBePacked = async rootDir => { const {files} = JSON.parse(stdout).at(0); return files.map(file => file.path); }; - -export const getRegistryUrl = async (pkgManager, pkg) => { - if (pkgManager === 'yarn-berry') { - const {stdout} = await execa('yarn', ['config', 'get', 'npmRegistryServer']); - return stdout; - } - - const args = ['config', 'get', 'registry']; - if (isExternalRegistry(pkg)) { - args.push('--registry', pkg.publishConfig.registry); - } - - const {stdout} = await execa(pkgManager, args); - return stdout; -}; diff --git a/source/package-manager/configs.js b/source/package-manager/configs.js new file mode 100644 index 00000000..45bfc456 --- /dev/null +++ b/source/package-manager/configs.js @@ -0,0 +1,51 @@ +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const npmConfig = { + cli: 'npm', + id: 'npm', + installCommand: ['npm', ['ci', '--engine-strict']], + installCommandNoLockfile: ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict']], + versionCommand: version => ['npm', ['version', version]], + getRegistryCommand: ['npm', ['config', 'get', 'registry']], + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix']], + lockfiles: ['package-lock.json', 'npm-shrinkwrap.json'], +}; + +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const pnpmConfig = { + cli: 'pnpm', + id: 'pnpm', + installCommand: ['pnpm', ['install']], + installCommandNoLockfile: ['pnpm', ['install']], + versionCommand: version => ['pnpm', ['version', version]], + tagVersionPrefixCommand: ['pnpm', ['config', 'get', 'tag-version-prefix']], + getRegistryCommand: ['pnpm', ['config', 'get', 'registry']], + lockfiles: ['pnpm-lock.yaml'], +}; + +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const yarnConfig = { + cli: 'yarn', + id: 'yarn', + installCommand: ['yarn', ['install', '--frozen-lockfile', '--production=false']], + installCommandNoLockfile: ['yarn', ['install', '--production=false']], + getRegistryCommand: ['yarn', ['config', 'get', 'registry']], + tagVersionPrefixCommand: ['yarn', ['config', 'get', 'version-tag-prefix']], + versionCommand: version => ['yarn', ['version', '--new-version', version]], + lockfiles: ['yarn.lock'], +}; + +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const yarnBerryConfig = { + cli: 'yarn', + id: 'yarn-berry', + installCommand: ['yarn', ['install', '--immutable']], + installCommandNoLockfile: ['yarn', ['install']], + // Yarn berry doesn't support git committing/tagging, so we use npm instead + versionCommand: version => ['npm', ['version', version]], + tagVersionPrefixCommand: ['yarn', ['config', 'get', 'version-tag-prefix']], + // Yarn berry offloads publishing to npm, e.g. `yarn npm publish x.y.z` + publishCommand: args => ['yarn', ['npm', ...args]], + getRegistryCommand: ['yarn', ['config', 'get', 'npmRegistryServer']], + throwOnExternalRegistry: true, + lockfiles: ['yarn.lock'], +}; diff --git a/source/package-manager/index.js b/source/package-manager/index.js new file mode 100644 index 00000000..93252f72 --- /dev/null +++ b/source/package-manager/index.js @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import semver from 'semver'; +import * as configs from './configs.js'; + +/** +@param {string} rootDir +@param {import('./types.d.ts').PackageManagerConfig} config +*/ +export function findLockfile(rootDir, config) { + return config.lockfiles + .map(filename => path.resolve(rootDir || '.', filename)) + .find(filepath => fs.existsSync(filepath)); +} + +/** +@param {string} rootDir +@param {import('read-pkg').NormalizedPackageJson} pkg +*/ +export function getPackageManagerConfig(rootDir, pkg) { + const config = configFromPackageManagerField(pkg); + return config || configFromLockfile(rootDir) || configs.npmConfig; +} + +/** @param {import('read-pkg').NormalizedPackageJson} pkg */ +function configFromPackageManagerField(pkg) { + if (typeof pkg.packageManager !== 'string') { + return undefined; + } + + const [packageManager, version] = pkg.packageManager.split('@'); + + if (packageManager === 'yarn' && version && semver.gte(version, '2.0.0')) { + return configs.yarnBerryConfig; + } + + if (packageManager === 'npm') { + return configs.npmConfig; + } + + if (packageManager === 'pnpm') { + return configs.pnpmConfig; + } + + if (packageManager === 'yarn') { + return configs.yarnConfig; + } + + throw new Error(`Invalid package manager: ${pkg.packageManager}`); +} + +/** @param {string} rootDir */ +function configFromLockfile(rootDir, options = [configs.npmConfig, configs.pnpmConfig, configs.yarnConfig]) { + return options.find(config => findLockfile(rootDir, config)); +} + +/** @param {import('./types.d.ts').Command} command */ +export function printCommand([cli, args]) { + return `${cli} ${args.join(' ')}`; +} diff --git a/source/package-manager/types.d.ts b/source/package-manager/types.d.ts new file mode 100644 index 00000000..7a3d6264 --- /dev/null +++ b/source/package-manager/types.d.ts @@ -0,0 +1,58 @@ +export type PackageManager = 'npm' | 'yarn' | 'pnpm'; + +/** +CLI and arguments, which can be passed to `execa`. +*/ +export type Command = [cli: string, args: string[]]; + +export type PackageManagerConfig = { + /** + The main CLI, e.g. the `npm` in `npm install`, `npm test`, etc. + */ + cli: PackageManager; + + /** + How the package manager should be referred to in user-facing messages (since there are two different configs for some, e.g. yarn and yarn-berry). + */ + id: string; + + /** + How to install packages when there is a lockfile, e.g. `["npm", ["install"]]`. + */ + installCommand: Command; + + /** + How to install packages when there is no lockfile, e.g. `["npm", ["install"]]`. + */ + installCommandNoLockfile: Command; + + /** + Given a version string, return a version command e.g. `version => ["npm", ["version", version]]`. + */ + versionCommand: (version: string) => [cli: string, args: string[]]; + + /** + Modify the actual publish command. Defaults to `args => [config.cli, args]`. + */ + publishCommand?: (args: string[]) => Command; + + /** + CLI command which is expected to output the npm registry to use, e.g. `['npm', ['config', 'get', 'registry']]`. + */ + getRegistryCommand: Command; + + /** + CLI command expected to output the version tag prefix (often "v"). e,g. `['npm', ['config', 'get', 'tag-version-prefix']]`. + */ + tagVersionPrefixCommand: Command; + + /** + Set to true if the package manager doesn't support external registries. `np` will throw if one is detected and this is set. + */ + throwOnExternalRegistry?: boolean; + + /** + List of lockfile names expected for this package manager, relative to CWD. e.g. `['package-lock.json', 'npm-shrinkwrap.json']`. + */ + lockfiles: string[]; +}; diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 4f9fc597..529821a6 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -6,7 +6,7 @@ import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -const prerequisiteTasks = (input, pkg, options) => { +const prerequisiteTasks = (input, pkg, options, pkgManager) => { const isExternalRegistry = npm.isExternalRegistry(pkg); let newVersion; @@ -17,15 +17,10 @@ const prerequisiteTasks = (input, pkg, options) => { task: async () => npm.checkConnection(), }, { - title: 'Check npm version', - task: async () => npm.verifyRecentNpmVersion(), - }, - { - title: 'Check yarn version', - enabled: () => options.yarn === true, + title: `Check ${pkgManager.cli} version`, async task() { - const {stdout: yarnVersion} = await execa('yarn', ['--version']); - util.validateEngineVersionSatisfies('yarn', yarnVersion); + const {stdout: version} = await execa(pkgManager.cli, ['--version']); + util.validateEngineVersionSatisfies(pkgManager.cli, version); }, }, { @@ -77,7 +72,7 @@ const prerequisiteTasks = (input, pkg, options) => { async task() { await git.fetch(); - const tagPrefix = await util.getTagVersionPrefix(options); + const tagPrefix = await util.getTagVersionPrefix(pkgManager); await git.verifyTagDoesNotExistOnRemote(`${tagPrefix}${newVersion}`); }, diff --git a/source/release-task-helper.js b/source/release-task-helper.js index ebaf2269..7f09c9f5 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -3,12 +3,12 @@ import newGithubReleaseUrl from 'new-github-release-url'; import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; -const releaseTaskHelper = async (options, pkg) => { +const releaseTaskHelper = async (options, pkg, pkgManager) => { const newVersion = options.releaseDraftOnly ? new Version(pkg.version) - : new Version(pkg.version).setFrom(options.version.toString(), {prereleasePrefix: await getPreReleasePrefix(options)}); + : new Version(pkg.version).setFrom(options.version.toString(), {prereleasePrefix: await getPreReleasePrefix(pkgManager)}); - const tag = await getTagVersionPrefix(options) + newVersion.toString(); + const tag = await getTagVersionPrefix(pkgManager) + newVersion.toString(); const url = newGithubReleaseUrl({ repoUrl: options.repoUrl, diff --git a/source/ui.js b/source/ui.js index 3fb1a607..115c08a8 100644 --- a/source/ui.js +++ b/source/ui.js @@ -4,6 +4,8 @@ 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 {getPackageManagerConfig} from './package-manager/index.js'; import Version, {SEMVER_INCREMENTS} from './version.js'; import * as util from './util.js'; import * as git from './git-util.js'; @@ -119,29 +121,22 @@ const checkNewFilesAndDependencies = async (pkg, rootDir) => { return answers.confirm; }; -// eslint-disable-next-line complexity -const ui = async (options, {pkg, rootDir, isYarnBerry = false}) => { +/** +@param {import('./cli-implementation.js').CLI['flags']} options +@param {{pkg: import('read-pkg').NormalizedPackageJson; rootDir: string}} context +*/ +const ui = async (options, {pkg, rootDir}) => { const oldVersion = pkg.version; const extraBaseUrls = ['gitlab.com']; const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); - const pkgManager = (() => { - if (!options.yarn) { - return 'npm'; - } - - if (isYarnBerry) { - return 'yarn-berry'; - } - - return 'yarn'; - })(); + const pkgManager = getPackageManagerConfig(rootDir, pkg); - if (isYarnBerry && npm.isExternalRegistry(pkg)) { - throw new Error('External registry is not yet supported with Yarn Berry'); + if (pkgManager.throwOnExternalRegistry && npm.isExternalRegistry(pkg)) { + throw new Error(`External registry is not yet supported with ${pkgManager.id}.`); } - const registryUrl = await npm.getRegistryUrl(pkgManager, pkg); + const {stdout: registryUrl} = await execa(...pkgManager.getRegistryCommand); const releaseBranch = options.branch; if (options.runPublish) { @@ -160,7 +155,7 @@ const ui = async (options, {pkg, rootDir, isYarnBerry = false}) => { console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(pkg.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(options)}).format()})`) + ? chalk.dim(`(current: ${oldVersion}, next: ${new Version(oldVersion, options.version, {prereleasePrefix: await util.getPreReleasePrefix(pkgManager)}).format()})`) : chalk.dim(`(current: ${oldVersion})`); console.log(`\nPublish a new version of ${chalk.bold.magenta(pkg.name)} ${versionText}\n`); @@ -241,7 +236,7 @@ const ui = async (options, {pkg, rootDir, isYarnBerry = false}) => { && !options.tag ); - const alreadyPublicScoped = isYarnBerry && options.runPublish && await util.getNpmPackageAccess(pkg.name) === 'public'; + const alreadyPublicScoped = pkgManager.id === 'yarn-berry' && options.runPublish && await util.getNpmPackageAccess(pkg.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. diff --git a/source/util.js b/source/util.js index 379aba31..e0ea3e66 100644 --- a/source/util.js +++ b/source/util.js @@ -60,13 +60,12 @@ export const linkifyCommitRange = (url, commitRange) => { return terminalLink(commitRange, `${url}/compare/${commitRange}`); }; -export const getTagVersionPrefix = pMemoize(async options => { - ow(options, ow.object.hasKeys('yarn')); +/** @type {(config: import('./package-manager/types.js').PackageManagerConfig) => Promise} */ +export const getTagVersionPrefix = pMemoize(async config => { + ow(config, ow.object.hasKeys('tagVersionPrefixCommand')); try { - const {stdout} = options.yarn - ? await execa('yarn', ['config', 'get', 'version-tag-prefix']) - : await execa('npm', ['config', 'get', 'tag-version-prefix']); + const {stdout} = await execa(...config.tagVersionPrefixCommand); return stdout; } catch { @@ -131,12 +130,12 @@ export const getNewDependencies = async (newPkg, rootDir) => { return newDependencies; }; -export const getPreReleasePrefix = pMemoize(async options => { - ow(options, ow.object.hasKeys('yarn')); +/** @type {(config: import('./package-manager/types.js').PackageManagerConfig) => Promise} */ +export const getPreReleasePrefix = pMemoize(async config => { + ow(config, ow.object.hasKeys('cli')); try { - const packageManager = options.yarn ? 'yarn' : 'npm'; - const {stdout} = await execa(packageManager, ['config', 'get', 'preid']); + const {stdout} = await execa(config.cli, ['config', 'get', 'preid']); return stdout === 'undefined' ? '' : stdout; } catch { diff --git a/source/yarn.js b/source/yarn.js deleted file mode 100644 index d4da4275..00000000 --- a/source/yarn.js +++ /dev/null @@ -1,16 +0,0 @@ -import semver from 'semver'; - -export function checkIfYarnBerry(pkg) { - if (typeof pkg.packageManager !== 'string') { - return false; - } - - const match = pkg.packageManager.match(/^yarn@(.+)$/); - if (!match) { - return false; - } - - const [, yarnVersion] = match; - const versionParsed = semver.parse(yarnVersion); - return (versionParsed.major >= 2); -} diff --git a/test/cli.js b/test/cli.js index c898fd67..2406bd82 100644 --- a/test/cli.js +++ b/test/cli.js @@ -24,13 +24,13 @@ test('flags: --help', cliPasses, cli, '--help', [ '--no-publish Skips publishing', '--preview Show tasks without actually executing them', '--tag Publish under a given dist-tag', - '--no-yarn Don\'t use Yarn', '--contents Subdirectory to publish', '--no-release-draft Skips opening a GitHub release draft', '--release-draft-only Only opens a GitHub release draft for the latest published version', '--test-script Name of npm run script to run tests before publishing (default: test)', '--no-2fa Don\'t enable 2FA on new packages (not recommended)', '--message Version bump commit message, \'%s\' will be replaced with version (default: \'%s\' with npm and \'v%s\' with yarn)', + '--package-manager Use a specific package manager (default: \'packageManager\' field in package.json)', '', 'Examples', '$ np', diff --git a/test/git-util/check-if-file-git-ignored.js b/test/git-util/check-if-file-git-ignored.js deleted file mode 100644 index 1c5ba52b..00000000 --- a/test/git-util/check-if-file-git-ignored.js +++ /dev/null @@ -1,20 +0,0 @@ -import test from 'ava'; -import {temporaryDirectory} from 'tempy'; -import {checkIfFileGitIgnored} from '../../source/git-util.js'; - -test('returns true for ignored files', async t => { - t.true(await checkIfFileGitIgnored('yarn.lock')); -}); - -test('returns false for non-ignored files', async t => { - t.false(await checkIfFileGitIgnored('package.json')); -}); - -test('errors if path is outside of repo', async t => { - const temporary = temporaryDirectory(); - - await t.throwsAsync( - checkIfFileGitIgnored(`${temporary}/file.js`), - {message: /fatal:/}, - ); -}); diff --git a/test/index.js b/test/index.js index bf3ac759..7e3b3da2 100644 --- a/test/index.js +++ b/test/index.js @@ -40,18 +40,24 @@ test('errors on too low version', npFails, /New version 1\.0\.0(?:-beta)? should be higher than current version \d+\.\d+\.\d+/, ); +const fakeExecaReturn = () => Object.assign( + Promise.resolve({pipe: sinon.stub()}), + {stdout: '', stderr: ''}, +); + test('skip enabling 2FA if the package exists', async t => { const enable2faStub = sinon.stub(); /** @type {typeof np} */ const npMock = await esmock('../source/index.js', { del: {deleteAsync: sinon.stub()}, - execa: {execa: sinon.stub().returns({pipe: sinon.stub()})}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, '../source/prerequisite-tasks.js': sinon.stub(), '../source/git-tasks.js': sinon.stub(), '../source/git-util.js': { hasUpstream: sinon.stub().returns(true), pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), }, '../source/npm/enable-2fa.js': enable2faStub, '../source/npm/publish.js': sinon.stub().returns({pipe: sinon.stub()}), @@ -74,12 +80,13 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { /** @type {typeof np} */ const npMock = await esmock('../source/index.js', { del: {deleteAsync: sinon.stub()}, - execa: {execa: sinon.stub().returns({pipe: sinon.stub()})}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, '../source/prerequisite-tasks.js': sinon.stub(), '../source/git-tasks.js': sinon.stub(), '../source/git-util.js': { hasUpstream: sinon.stub().returns(true), pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), }, '../source/npm/enable-2fa.js': enable2faStub, '../source/npm/publish.js': sinon.stub().returns({pipe: sinon.stub()}), diff --git a/test/npm/util/get-registry-url.js b/test/npm/util/get-registry-url.js deleted file mode 100644 index 318804da..00000000 --- a/test/npm/util/get-registry-url.js +++ /dev/null @@ -1,49 +0,0 @@ -import test from 'ava'; -import {_createFixture} from '../../_helpers/stub-execa.js'; - -/** @type {ReturnType>} */ -const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); - -test('npm', createFixture, [{ - command: 'npm config get registry', - stdout: 'https://registry.npmjs.org/', -}], async ({t, testedModule: npm}) => { - t.is( - await npm.getRegistryUrl('npm', {}), - 'https://registry.npmjs.org/', - ); -}); - -test('yarn', createFixture, [{ - command: 'yarn config get registry', - stdout: 'https://registry.yarnpkg.com', -}], async ({t, testedModule: npm}) => { - t.is( - await npm.getRegistryUrl('yarn', {}), - 'https://registry.yarnpkg.com', - ); -}); - -test('yarn-berry', createFixture, [{ - command: 'yarn config get npmRegistryServer', - stdout: 'https://registry.yarnpkg.com', -}], async ({t, testedModule: npm}) => { - t.is( - await npm.getRegistryUrl('yarn-berry', {}), - 'https://registry.yarnpkg.com', - ); -}); - -test('external', createFixture, [{ - command: 'npm config get registry --registry http://my-internal-registry.local', - stdout: 'http://my-internal-registry.local', -}], async ({t, testedModule: npm}) => { - t.is( - await npm.getRegistryUrl('npm', { - publishConfig: { - registry: 'http://my-internal-registry.local', - }, - }), - 'http://my-internal-registry.local', - ); -}); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 2363ff0a..b7480912 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -1,5 +1,6 @@ import process from 'node:process'; import test from 'ava'; +import {npmConfig, yarnConfig} from '../../source/package-manager/configs.js'; import actualPrerequisiteTasks from '../../source/prerequisite-tasks.js'; import {npPkg} from '../../source/util.js'; import {SilentRenderer} from '../_helpers/listr-renderer.js'; @@ -21,7 +22,7 @@ test.serial('public-package published on npm registry: should fail when npm regi stderr: 'failed', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('1.0.0', {name: 'test'}, {})), + run(prerequisiteTasks('1.0.0', {name: 'test'}, {}, npmConfig)), {message: 'Connection to npm registry failed'}, ); @@ -33,7 +34,7 @@ test.serial('private package: should disable task pinging npm registry', createF stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig)), ); assertTaskDisabled(t, 'Ping npm registry'); @@ -44,7 +45,7 @@ test.serial('external registry: should disable task pinging npm registry', creat stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, npmConfig)), ); assertTaskDisabled(t, 'Ping npm registry'); @@ -63,7 +64,7 @@ test.serial('should fail when npm version does not match range in `package.json` const depRange = npPkg.engines.npm; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: `\`np\` requires npm ${depRange}`}, ); @@ -83,7 +84,7 @@ test.serial('should fail when yarn version does not match range in `package.json const depRange = npPkg.engines.yarn; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: true})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, yarnConfig)), {message: `\`np\` requires yarn ${depRange}`}, ); @@ -103,7 +104,7 @@ test.serial('should fail when user is not authenticated at npm registry', create process.env.NODE_ENV = 'P'; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'You do not have write permissions required to publish this package.'}, ); @@ -125,7 +126,7 @@ test.serial('should fail when user is not authenticated at external registry', c process.env.NODE_ENV = 'P'; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, npmConfig)), {message: 'You do not have write permissions required to publish this package.'}, ); @@ -143,7 +144,7 @@ test.serial('private package: should disable task `verify user is authenticated` process.env.NODE_ENV = 'P'; await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig)), ); process.env.NODE_ENV = 'test'; @@ -158,7 +159,7 @@ test.serial('should fail when git version does not match range in `package.json` const depRange = npPkg.engines.git; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: `\`np\` requires git ${depRange}`}, ); @@ -172,7 +173,7 @@ test.serial('should fail when git remote does not exist', createFixture, [{ stderr: 'not found', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'not found'}, ); @@ -181,7 +182,7 @@ test.serial('should fail when git remote does not exist', createFixture, [{ test.serial('should fail when version is invalid', async t => { await t.throwsAsync( - run(actualPrerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(actualPrerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'New version DDD should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version.'}, ); @@ -190,7 +191,7 @@ test.serial('should fail when version is invalid', async t => { test.serial('should fail when version is lower than latest version', async t => { await t.throwsAsync( - run(actualPrerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(actualPrerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'New version 0.1.0 should be higher than current version 1.0.0.'}, ); @@ -199,7 +200,7 @@ test.serial('should fail when version is lower than latest version', async t => test.serial('should fail when prerelease version of public package without dist tag given', async t => { await t.throwsAsync( - run(actualPrerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(actualPrerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'}, ); @@ -211,7 +212,7 @@ test.serial('should not fail when prerelease version of public package with dist stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {yarn: false, tag: 'pre'})), + run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {tag: 'pre'}, npmConfig)), ); }); @@ -220,7 +221,7 @@ test.serial('should not fail when prerelease version of private package without stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {yarn: false})), + run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig)), ); }); @@ -229,7 +230,7 @@ test.serial('should fail when git tag already exists', createFixture, [{ stdout: 'vvb', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'Git tag `v2.0.0` already exists.'}, ); @@ -241,6 +242,6 @@ test.serial('checks should pass', createFixture, [{ stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {yarn: false})), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), ); }); diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index 7c548cd7..fc5eb792 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -43,7 +43,6 @@ const createFixture = test.macro(async (t, pkg, commands, expected) => { const {ui, logs: logsArray} = await mockInquirer({t, answers: {confirm: {confirm: false}}, mocks: { './npm/util.js': { - getRegistryUrl: sinon.stub().resolves(''), checkIgnoreStrategy: sinon.stub().resolves(), }, 'node:process': {cwd: () => temporaryDir}, diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js index 52423b40..1cf6acdc 100644 --- a/test/ui/prompts/tags.js +++ b/test/ui/prompts/tags.js @@ -6,7 +6,6 @@ import {mockInquirer} from '../../_helpers/mock-inquirer.js'; const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { const {ui, logs} = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { './npm/util.js': { - getRegistryUrl: sinon.stub().resolves(''), checkIgnoreStrategy: sinon.stub().resolves(), prereleaseTags: sinon.stub().resolves(tags), }, @@ -18,6 +17,9 @@ const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { latestTagOrFirstCommit: sinon.stub().resolves(`v${npPkg.version}`), commitLogFromRevision: sinon.stub().resolves(''), }, + './package-manager/index.js': { + findLockfile: sinon.stub().resolves(undefined), + }, }}); const results = await ui({ diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index eb1021fb..8cb125e9 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -5,7 +5,6 @@ import {mockInquirer} from '../../_helpers/mock-inquirer.js'; const testUi = test.macro(async (t, {version, answers}, assertions) => { const {ui, logs} = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { './npm/util.js': { - getRegistryUrl: sinon.stub().resolves(''), checkIgnoreStrategy: sinon.stub().resolves(), }, './util.js': { diff --git a/test/util/get-pre-release-prefix.js b/test/util/get-pre-release-prefix.js index 113294c7..f99b2afc 100644 --- a/test/util/get-pre-release-prefix.js +++ b/test/util/get-pre-release-prefix.js @@ -12,7 +12,7 @@ test('returns preid postfix if set - npm', createFixture, [{ stdout: 'pre', }], async ({t, testedModule: {getPreReleasePrefix}}) => { t.is( - await getPreReleasePrefix({yarn: false}), + await getPreReleasePrefix({cli: 'npm'}), 'pre', ); }); @@ -22,7 +22,7 @@ test('returns preid postfix if set - yarn', createFixture, [{ stdout: 'pre', }], async ({t, testedModule: {getPreReleasePrefix}}) => { t.is( - await getPreReleasePrefix({yarn: true}), + await getPreReleasePrefix({cli: 'yarn'}), 'pre', ); }); @@ -32,7 +32,7 @@ test('returns empty string if not set - npm', createFixture, [{ stdout: 'undefined', }], async ({t, testedModule: {getPreReleasePrefix}}) => { t.is( - await getPreReleasePrefix({yarn: false}), + await getPreReleasePrefix({cli: 'npm'}), '', ); }); @@ -42,7 +42,7 @@ test('returns empty string if not set - yarn', createFixture, [{ stdout: 'undefined', }], async ({t, testedModule: {getPreReleasePrefix}}) => { t.is( - await getPreReleasePrefix({yarn: true}), + await getPreReleasePrefix({cli: 'yarn'}), '', ); }); @@ -52,13 +52,13 @@ test('no options passed', async t => { originalGetPreReleasePrefix(), {message: stripIndent` Expected argument to be of type \`object\` but received type \`undefined\` - Expected object to have keys \`["yarn"]\` + Expected object to have keys \`["cli"]\` `}, ); await t.throwsAsync( originalGetPreReleasePrefix({}), - {message: 'Expected object to have keys `["yarn"]`'}, + {message: 'Expected object to have keys `["cli"]`'}, ); }); @@ -66,7 +66,7 @@ test.serial('returns actual value', async t => { const originalPreid = process.env.NPM_CONFIG_PREID; process.env.NPM_CONFIG_PREID = 'beta'; - t.is(await originalGetPreReleasePrefix({yarn: false}), 'beta'); + t.is(await originalGetPreReleasePrefix({cli: 'npm'}), 'beta'); process.env.NPM_CONFIG_PREID = originalPreid; }); diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js index aa387110..db714847 100644 --- a/test/util/get-tag-version-prefix.js +++ b/test/util/get-tag-version-prefix.js @@ -2,6 +2,7 @@ import test from 'ava'; import {stripIndent} from 'common-tags'; import {_createFixture} from '../_helpers/stub-execa.js'; import {getTagVersionPrefix as originalGetTagVersionPrefix} from '../../source/util.js'; +import {npmConfig, yarnConfig} from '../../source/package-manager/configs.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/util.js', import.meta.url); @@ -11,7 +12,7 @@ test('returns tag prefix - npm', createFixture, [{ stdout: 'ver', }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( - await getTagVersionPrefix({yarn: false}), + await getTagVersionPrefix(npmConfig), 'ver', ); }); @@ -21,7 +22,7 @@ test('returns preId postfix - yarn', createFixture, [{ stdout: 'ver', }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( - await getTagVersionPrefix({yarn: true}), + await getTagVersionPrefix(yarnConfig), 'ver', ); }); @@ -31,7 +32,7 @@ test('defaults to "v" when command fails', createFixture, [{ exitCode: 1, }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( - await getTagVersionPrefix({yarn: false}), + await getTagVersionPrefix(npmConfig), 'v', ); }); @@ -41,12 +42,12 @@ test('no options passed', async t => { originalGetTagVersionPrefix(), {message: stripIndent` Expected argument to be of type \`object\` but received type \`undefined\` - Expected object to have keys \`["yarn"]\` + Expected object to have keys \`["tagVersionPrefixCommand"]\` `}, ); await t.throwsAsync( originalGetTagVersionPrefix({}), - {message: 'Expected object to have keys `["yarn"]`'}, + {message: 'Expected object to have keys `["tagVersionPrefixCommand"]`'}, ); }); diff --git a/test/util/yarn.js b/test/util/yarn.js deleted file mode 100644 index bae4fd36..00000000 --- a/test/util/yarn.js +++ /dev/null @@ -1,15 +0,0 @@ -import test from 'ava'; -import {checkIfYarnBerry} from '../../source/yarn.js'; - -test('checkIfYarnBerry', t => { - t.is(checkIfYarnBerry({}), false); - t.is(checkIfYarnBerry({ - packageManager: 'npm', - }), false); - t.is(checkIfYarnBerry({ - packageManager: 'yarn@1.0.0', - }), false); - t.is(checkIfYarnBerry({ - packageManager: 'yarn@2.0.0', - }), true); -}); From 587d6f651bdfe9c628dfc9b45b01b9dd105718cc Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 26 Feb 2024 20:27:48 +0700 Subject: [PATCH 079/180] Meta tweaks --- package.json | 18 ++--- source/cli-implementation.js | 22 +++--- source/git-util.js | 8 +- source/index.js | 78 ++++++++++--------- source/npm/enable-2fa.js | 10 +-- source/npm/publish.js | 12 +-- source/npm/util.js | 38 ++++----- source/package-manager/configs.js | 2 +- source/package-manager/index.js | 36 ++++----- source/package-manager/types.d.ts | 2 +- source/prerequisite-tasks.js | 24 +++--- source/release-task-helper.js | 8 +- source/ui.js | 56 ++++++------- source/util.js | 36 ++++----- source/version.js | 10 +-- test/_helpers/integration-test.d.ts | 12 +-- test/_helpers/integration-test.js | 26 ++++--- test/_helpers/mock-inquirer.js | 2 +- test/_helpers/stub-execa.d.ts | 4 +- test/_helpers/stub-execa.js | 12 +-- test/_helpers/util.js | 6 +- test/_helpers/verify-cli.js | 4 +- test/cli.js | 6 +- test/config.js | 26 +++---- test/git-util/new-files-since-last-release.js | 12 +-- test/git-util/root.js | 8 +- test/index.js | 8 +- test/npm/enable-2fa.js | 12 +-- test/npm/util/check-ignore-strategy.js | 8 +- test/npm/util/collaborators.js | 2 +- test/npm/util/is-package-name-available.js | 4 +- test/npm/util/packed-files.js | 8 +- test/tasks/prerequisite-tasks.js | 8 +- test/ui/new-files-dependencies.d.ts | 10 +-- test/ui/new-files-dependencies.js | 54 ++++++------- test/ui/prompts/tags.js | 38 ++++----- test/ui/prompts/version.js | 28 +++---- test/util/get-new-dependencies.js | 32 ++++---- test/util/get-new-files.js | 26 +++---- test/util/get-pre-release-prefix.js | 6 +- test/util/get-tag-version-prefix.js | 6 +- test/util/read-pkg.js | 36 ++++----- .../util/validate-engine-version-satisfies.js | 4 +- 43 files changed, 393 insertions(+), 375 deletions(-) diff --git a/package.json b/package.json index 4d266119..a1edf0bc 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "repository": "sindresorhus/np", "funding": "https://github.com/sindresorhus/np?sponsor=1", "type": "module", - "bin": "source/cli.js", + "bin": "./source/cli.js", "engines": { "node": ">=18", "npm": ">=9", @@ -44,7 +44,7 @@ "hosted-git-info": "^7.0.1", "ignore-walk": "^6.0.3", "import-local": "^3.1.0", - "inquirer": "^9.2.12", + "inquirer": "^9.2.15", "is-installed-globally": "^1.0.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", @@ -54,9 +54,9 @@ "log-symbols": "^6.0.0", "meow": "^13.1.0", "new-github-release-url": "^2.0.0", - "npm-name": "^7.1.1", + "npm-name": "^8.0.0", "onetime": "^7.0.0", - "open": "^9.1.0", + "open": "^10.0.4", "ow": "^1.1.1", "p-memoize": "^7.1.1", "p-timeout": "^6.1.2", @@ -65,24 +65,24 @@ "read-package-up": "^11.0.0", "read-pkg": "^9.0.1", "rxjs": "^7.8.1", - "semver": "^7.5.4", + "semver": "^7.6.0", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", "update-notifier": "^7.0.0" }, "devDependencies": { "@sindresorhus/is": "^6.1.0", - "@types/semver": "^7.5.6", + "@types/semver": "^7.5.8", "ava": "^5.3.1", "common-tags": "^1.8.2", - "esmock": "^2.6.0", + "esmock": "^2.6.3", "fs-extra": "^11.1.1", "map-obj": "^5.0.2", "sinon": "^17.0.1", "strip-ansi": "^7.1.0", "tempy": "^3.1.0", - "write-package": "^7.0.0", - "xo": "^0.56.0" + "write-package": "^7.0.1", + "xo": "^0.57.0" }, "ava": { "files": [ diff --git a/source/cli-implementation.js b/source/cli-implementation.js index c13fa608..a6b3fd6b 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -107,9 +107,9 @@ updateNotifier({pkg: cli.pkg}).notify(); /** @typedef {Awaited>['options']} Options */ export async function getOptions() { - const {pkg, rootDir} = await util.readPkg(cli.flags.contents); + const {package_, rootDirectory} = await util.readPackage(cli.flags.contents); - const localConfig = await config(rootDir); + const localConfig = await config(rootDirectory); const flags = { ...localConfig, ...cli.flags, @@ -121,19 +121,19 @@ export async function getOptions() { } if (flags.packageManager) { - pkg.packageManager = flags.packageManager; + package_.packageManager = flags.packageManager; } - const runPublish = !flags.releaseDraftOnly && flags.publish && !pkg.private; + const runPublish = !flags.releaseDraftOnly && flags.publish && !package_.private; // TODO: does this need to run if `runPublish` is false? - const availability = runPublish ? await npm.isPackageNameAvailable(pkg) : { + const availability = runPublish ? await npm.isPackageNameAvailable(package_) : { isAvailable: false, isUnknown: false, }; // Use current (latest) version when 'releaseDraftOnly', otherwise try to use the first argument. - const version = flags.releaseDraftOnly ? pkg.version : cli.input.at(0); + const version = flags.releaseDraftOnly ? package_.version : cli.input.at(0); const branch = flags.branch ?? await git.defaultBranch(); @@ -143,26 +143,26 @@ export async function getOptions() { availability, version, branch, - }, {pkg, rootDir}); + }, {package_, rootDirectory}); - return {options, rootDir, pkg}; + return {options, rootDirectory, package_}; } try { - const {options, rootDir, pkg} = await getOptions(); + const {options, rootDirectory, package_} = await getOptions(); if (!options.confirm) { gracefulExit(); } console.log(); // Prints a newline for readability - const newPkg = await np(options.version, options, {pkg, rootDir}); + const newPackage = await np(options.version, options, {package_, rootDirectory}); if (options.preview || options.releaseDraftOnly) { gracefulExit(); } - console.log(`\n ${newPkg.name} ${newPkg.version} published πŸŽ‰`); + console.log(`\n ${newPackage.name} ${newPackage.version} published πŸŽ‰`); } catch (error) { console.error(`\n${logSymbols.error} ${error?.stack ?? error}`); gracefulExit(1); diff --git a/source/git-util.js b/source/git-util.js index 037d4a27..c9b775a5 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -14,7 +14,7 @@ export const root = async () => { return stdout; }; -export const newFilesSinceLastRelease = async rootDir => { +export const newFilesSinceLastRelease = async rootDirectory => { try { const {stdout} = await execa('git', ['diff', '--name-only', '--diff-filter=A', await latestTag(), 'HEAD']); if (stdout.trim().length === 0) { @@ -26,7 +26,7 @@ export const newFilesSinceLastRelease = async rootDir => { } catch { // Get all files under version control return ignoreWalker({ - path: rootDir, + path: rootDirectory, ignoreFiles: ['.gitignore'], }); } @@ -234,8 +234,8 @@ export const commitLogFromRevision = async revision => { return stdout; }; -const push = async (tagArg = '--follow-tags') => { - await execa('git', ['push', tagArg]); +const push = async (tagArgument = '--follow-tags') => { + await execa('git', ['push', tagArgument]); }; export const pushGraceful = async remoteIsOnGitHub => { diff --git a/source/index.js b/source/index.js index f5484dba..833b2032 100644 --- a/source/index.js +++ b/source/index.js @@ -1,7 +1,13 @@ import {execa} from 'execa'; import {deleteAsync} from 'del'; import Listr from 'listr'; -import {merge, catchError, filter, finalize, from} from 'rxjs'; +import { + merge, + catchError, + filter, + finalize, + from, +} from 'rxjs'; import hostedGitInfo from 'hosted-git-info'; import onetime from 'onetime'; import {asyncExitHook} from 'exit-hook'; @@ -9,7 +15,7 @@ import logSymbols from 'log-symbols'; import prerequisiteTasks from './prerequisite-tasks.js'; import gitTasks from './git-tasks.js'; import {getPackagePublishArguments} from './npm/publish.js'; -import enable2fa, {getEnable2faArgs} from './npm/enable-2fa.js'; +import enable2fa, {getEnable2faArguments} from './npm/enable-2fa.js'; import handleNpmError from './npm/handle-npm-error.js'; import releaseTaskHelper from './release-task-helper.js'; import {findLockfile, getPackageManagerConfig, printCommand} from './package-manager/index.js'; @@ -18,20 +24,20 @@ import * as git from './git-util.js'; import * as npm from './npm/util.js'; /** @type {(cmd: string, args: string[], options?: import('execa').Options) => any} */ -const exec = (cmd, args, options) => { +const exec = (command, arguments_, options) => { // Use `Observable` support if merged https://github.com/sindresorhus/execa/pull/26 - const cp = execa(cmd, args, options); + const subProcess = execa(command, arguments_, options); - return merge(cp.stdout, cp.stderr, cp).pipe(filter(Boolean)); + return merge(subProcess.stdout, subProcess.stderr, subProcess).pipe(filter(Boolean)); }; /** @param {string} input @param {import('./cli-implementation.js').Options} options -@param {{pkg: import('read-pkg').NormalizedPackageJson; rootDir: string}} context +@param {{package_: import('read-pkg').NormalizedPackageJson; rootDirectory: string}} context */ -const np = async (input = 'patch', options, {pkg, rootDir}) => { - const pkgManager = getPackageManagerConfig(rootDir, pkg); +const np = async (input = 'patch', options, {package_, rootDirectory}) => { + const packageManager = getPackageManagerConfig(rootDirectory, package_); // TODO: Remove sometime far in the future if (options.skipCleanup) { @@ -40,13 +46,13 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; - const lockfile = findLockfile(rootDir, pkgManager); + const lockfile = findLockfile(rootDirectory, packageManager); const isOnGitHub = options.repoUrl && hostedGitInfo.fromUrl(options.repoUrl)?.type === 'github'; const testScript = options.testScript || 'test'; if (options.releaseDraftOnly) { - await releaseTaskHelper(options, pkg, pkgManager); - return pkg; + await releaseTaskHelper(options, package_, packageManager); + return package_; } let publishStatus = 'UNKNOWN'; @@ -55,19 +61,19 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { const rollback = onetime(async () => { console.log('\nPublish failed. Rolling back to the previous state…'); - const tagVersionPrefix = await util.getTagVersionPrefix(pkgManager); + const tagVersionPrefix = await util.getTagVersionPrefix(packageManager); const latestTag = await git.latestTag(); const versionInLatestTag = latestTag.slice(tagVersionPrefix.length); - async function getPkgVersion() { - const pkg = await util.readPkg(rootDir); - return pkg.version; + async function getPackageVersion() { + const package_ = await util.readPackage(rootDirectory); + return package_.version; } try { // Verify that the package's version has been bumped before deleting the last tag and commit. - if (versionInLatestTag === await getPkgVersion() && versionInLatestTag !== pkg.version) { + if (versionInLatestTag === await getPackageVersion() && versionInLatestTag !== package_.version) { await git.deleteTag(latestTag); await git.removeLastCommit(); } @@ -90,23 +96,23 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { } }, {wait: 2000}); - const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !pkg.private && !npm.isExternalRegistry(pkg); + const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !package_.private && !npm.isExternalRegistry(package_); // To prevent the process from hanging due to watch mode (e.g. when running `vitest`) const ciEnvOptions = {env: {CI: 'true'}}; /** @param {typeof options} _options */ function getPublishCommand(_options) { - const publishCommand = pkgManager.publishCommand || (args => [pkgManager.cli, args]); - const args = getPackagePublishArguments(_options); - return publishCommand(args); + const publishCommand = packageManager.publishCommand || (arguments_ => [packageManager.cli, arguments_]); + const arguments_ = getPackagePublishArguments(_options); + return publishCommand(arguments_); } const tasks = new Listr([ { title: 'Prerequisite check', enabled: () => options.runPublish, - task: () => prerequisiteTasks(input, pkg, options, pkgManager), + task: () => prerequisiteTasks(input, package_, options, packageManager), }, { title: 'Git', @@ -118,13 +124,13 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { task: () => deleteAsync('node_modules'), }, { - title: `Installing dependencies using ${pkgManager.id}`, + title: `Installing dependencies using ${packageManager.id}`, enabled: () => runCleanup, task: () => new Listr([ { title: 'Running install command', task() { - const installCommand = lockfile ? pkgManager.installCommand : pkgManager.installCommandNoLockfile; + const installCommand = lockfile ? packageManager.installCommand : packageManager.installCommandNoLockfile; return exec(...installCommand); }, }, @@ -137,29 +143,29 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { { title: 'Running tests', enabled: () => runTests, - task: () => exec(pkgManager.cli, ['run', testScript], ciEnvOptions), + task: () => exec(packageManager.cli, ['run', testScript], ciEnvOptions), }, { title: 'Bumping version', skip() { if (options.preview) { - const [cli, args] = pkgManager.versionCommand(input); + const [cli, arguments_] = packageManager.versionCommand(input); if (options.message) { - args.push('--message', options.message.replaceAll('%s', input)); + arguments_.push('--message', options.message.replaceAll('%s', input)); } - return `[Preview] Command not executed: ${printCommand([cli, args])}`; + return `[Preview] Command not executed: ${printCommand([cli, arguments_])}`; } }, task() { - const [cli, args] = pkgManager.versionCommand(input); + const [cli, arguments_] = packageManager.versionCommand(input); if (options.message) { - args.push('--message', options.message); + arguments_.push('--message', options.message); } - return exec(cli, args); + return exec(cli, arguments_); }, }, ...options.runPublish ? [ @@ -199,11 +205,11 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { title: 'Enabling two-factor authentication', async skip() { if (options.preview) { - const args = await getEnable2faArgs(pkg.name, options); - return `[Preview] Command not executed: npm ${args.join(' ')}.`; + const arguments_ = await getEnable2faArguments(package_.name, options); + return `[Preview] Command not executed: npm ${arguments_.join(' ')}.`; } }, - task: (context, task) => enable2fa(task, pkg.name, {otp: context.otp}), + task: (context, task) => enable2fa(task, package_.name, {otp: context.otp}), }] : [], ] : [], { @@ -234,7 +240,7 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { } }, // TODO: parse version outside of index - task: () => releaseTaskHelper(options, pkg, pkgManager), + task: () => releaseTaskHelper(options, package_, packageManager), }] : [], ], { showSubtasks: false, @@ -251,8 +257,8 @@ const np = async (input = 'patch', options, {pkg, rootDir}) => { console.error(`\n${logSymbols.error} ${pushedObjects.reason}`); } - const {pkg: newPkg} = await util.readPkg(); - return newPkg; + const {package_: newPackage} = await util.readPackage(); + return newPackage; }; export default np; diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index 383182ec..55ffc032 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -4,20 +4,20 @@ import Version from '../version.js'; import handleNpmError from './handle-npm-error.js'; import {version as npmVersionCheck} from './util.js'; -export const getEnable2faArgs = async (packageName, options) => { +export const getEnable2faArguments = async (packageName, options) => { const npmVersion = await npmVersionCheck(); - const args = new Version(npmVersion).satisfies('>=9.0.0') + const arguments_ = new Version(npmVersion).satisfies('>=9.0.0') ? ['access', 'set', 'mfa=publish', packageName] : ['access', '2fa-required', packageName]; if (options && options.otp) { - args.push('--otp', options.otp); + arguments_.push('--otp', options.otp); } - return args; + return arguments_; }; -const enable2fa = (packageName, options) => execa('npm', getEnable2faArgs(packageName, options)); +const enable2fa = (packageName, options) => execa('npm', getEnable2faArguments(packageName, options)); const tryEnable2fa = (task, packageName, options) => { from(enable2fa(packageName, options)).pipe( diff --git a/source/npm/publish.js b/source/npm/publish.js index 8a499239..aeb2131e 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -1,21 +1,21 @@ export const getPackagePublishArguments = options => { - const args = ['publish']; + const arguments_ = ['publish']; if (options.contents) { - args.push(options.contents); + arguments_.push(options.contents); } if (options.tag) { - args.push('--tag', options.tag); + arguments_.push('--tag', options.tag); } if (options.otp) { - args.push('--otp', options.otp); + arguments_.push('--otp', options.otp); } if (options.publishScoped) { - args.push('--access', 'public'); + arguments_.push('--access', 'public'); } - return args; + return arguments_; }; diff --git a/source/npm/util.js b/source/npm/util.js index a3250832..e72177e2 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -27,14 +27,14 @@ export const checkConnection = () => pTimeout( ); export const username = async ({externalRegistry}) => { - const args = ['whoami']; + const arguments_ = ['whoami']; if (externalRegistry) { - args.push('--registry', externalRegistry); + arguments_.push('--registry', externalRegistry); } try { - const {stdout} = await execa('npm', args); + const {stdout} = await execa('npm', arguments_); return stdout; } catch (error) { const message = /ENEEDAUTH/.test(error.stderr) @@ -44,16 +44,16 @@ export const username = async ({externalRegistry}) => { } }; -export const isExternalRegistry = pkg => typeof pkg.publishConfig?.registry === 'string'; +export const isExternalRegistry = package_ => typeof package_.publishConfig?.registry === 'string'; -export const collaborators = async pkg => { - const packageName = pkg.name; +export const collaborators = async package_ => { + const packageName = package_.name; ow(packageName, ow.string); const arguments_ = ['access', 'list', 'collaborators', packageName, '--json']; - if (isExternalRegistry(pkg)) { - arguments_.push('--registry', pkg.publishConfig.registry); + if (isExternalRegistry(package_)) { + arguments_.push('--registry', package_.publishConfig.registry); } try { @@ -101,21 +101,21 @@ export const prereleaseTags = async packageName => { return tags; }; -export const isPackageNameAvailable = async pkg => { - const args = [pkg.name]; +export const isPackageNameAvailable = async package_ => { + const arguments_ = [package_.name]; const availability = { isAvailable: false, isUnknown: false, }; - if (isExternalRegistry(pkg)) { - args.push({ - registryUrl: pkg.publishConfig.registry, + if (isExternalRegistry(package_)) { + arguments_.push({ + registryUrl: package_.publishConfig.registry, }); } try { - availability.isAvailable = await npmName(...args) || false; + availability.isAvailable = await npmName(...arguments_) || false; } catch { availability.isUnknown = true; } @@ -128,18 +128,18 @@ export const verifyRecentNpmVersion = async () => { util.validateEngineVersionSatisfies('npm', npmVersion); }; -export const checkIgnoreStrategy = async ({files}, rootDir) => { - const npmignoreExistsInPackageRootDir = await pathExists(path.resolve(rootDir, '.npmignore')); +export const checkIgnoreStrategy = async ({files}, rootDirectory) => { + const npmignoreExistsInPackageRootDirectory = await pathExists(path.resolve(rootDirectory, '.npmignore')); - if (!files && !npmignoreExistsInPackageRootDir) { + if (!files && !npmignoreExistsInPackageRootDirectory) { console.log(chalk` \n{bold.yellow Warning:} No {bold.cyan files} field specified in {bold.magenta package.json} nor is a {bold.magenta .npmignore} file present. Having one of those will prevent you from accidentally publishing development-specific files along with your package's source code to npm. `); } }; -export const getFilesToBePacked = async rootDir => { - const {stdout} = await execa('npm', ['pack', '--dry-run', '--json'], {cwd: rootDir}); +export const getFilesToBePacked = async rootDirectory => { + const {stdout} = await execa('npm', ['pack', '--dry-run', '--json'], {cwd: rootDirectory}); const {files} = JSON.parse(stdout).at(0); return files.map(file => file.path); diff --git a/source/package-manager/configs.js b/source/package-manager/configs.js index 45bfc456..0f584bd7 100644 --- a/source/package-manager/configs.js +++ b/source/package-manager/configs.js @@ -44,7 +44,7 @@ export const yarnBerryConfig = { versionCommand: version => ['npm', ['version', version]], tagVersionPrefixCommand: ['yarn', ['config', 'get', 'version-tag-prefix']], // Yarn berry offloads publishing to npm, e.g. `yarn npm publish x.y.z` - publishCommand: args => ['yarn', ['npm', ...args]], + publishCommand: arguments_ => ['yarn', ['npm', ...arguments_]], getRegistryCommand: ['yarn', ['config', 'get', 'npmRegistryServer']], throwOnExternalRegistry: true, lockfiles: ['yarn.lock'], diff --git a/source/package-manager/index.js b/source/package-manager/index.js index 93252f72..19279191 100644 --- a/source/package-manager/index.js +++ b/source/package-manager/index.js @@ -4,31 +4,31 @@ import semver from 'semver'; import * as configs from './configs.js'; /** -@param {string} rootDir +@param {string} rootDirectory @param {import('./types.d.ts').PackageManagerConfig} config */ -export function findLockfile(rootDir, config) { +export function findLockfile(rootDirectory, config) { return config.lockfiles - .map(filename => path.resolve(rootDir || '.', filename)) + .map(filename => path.resolve(rootDirectory || '.', filename)) .find(filepath => fs.existsSync(filepath)); } /** -@param {string} rootDir -@param {import('read-pkg').NormalizedPackageJson} pkg +@param {string} rootDirectory +@param {import('read-pkg').NormalizedPackageJson} package_ */ -export function getPackageManagerConfig(rootDir, pkg) { - const config = configFromPackageManagerField(pkg); - return config || configFromLockfile(rootDir) || configs.npmConfig; +export function getPackageManagerConfig(rootDirectory, package_) { + const config = configFromPackageManagerField(package_); + return config || configFromLockfile(rootDirectory) || configs.npmConfig; } -/** @param {import('read-pkg').NormalizedPackageJson} pkg */ -function configFromPackageManagerField(pkg) { - if (typeof pkg.packageManager !== 'string') { +/** @param {import('read-pkg').NormalizedPackageJson} package_ */ +function configFromPackageManagerField(package_) { + if (typeof package_.packageManager !== 'string') { return undefined; } - const [packageManager, version] = pkg.packageManager.split('@'); + const [packageManager, version] = package_.packageManager.split('@'); if (packageManager === 'yarn' && version && semver.gte(version, '2.0.0')) { return configs.yarnBerryConfig; @@ -46,15 +46,15 @@ function configFromPackageManagerField(pkg) { return configs.yarnConfig; } - throw new Error(`Invalid package manager: ${pkg.packageManager}`); + throw new Error(`Invalid package manager: ${package_.packageManager}`); } -/** @param {string} rootDir */ -function configFromLockfile(rootDir, options = [configs.npmConfig, configs.pnpmConfig, configs.yarnConfig]) { - return options.find(config => findLockfile(rootDir, config)); +/** @param {string} rootDirectory */ +function configFromLockfile(rootDirectory, options = [configs.npmConfig, configs.pnpmConfig, configs.yarnConfig]) { + return options.find(config => findLockfile(rootDirectory, config)); } /** @param {import('./types.d.ts').Command} command */ -export function printCommand([cli, args]) { - return `${cli} ${args.join(' ')}`; +export function printCommand([cli, arguments_]) { + return `${cli} ${arguments_.join(' ')}`; } diff --git a/source/package-manager/types.d.ts b/source/package-manager/types.d.ts index 7a3d6264..7c18ddd1 100644 --- a/source/package-manager/types.d.ts +++ b/source/package-manager/types.d.ts @@ -34,7 +34,7 @@ export type PackageManagerConfig = { /** Modify the actual publish command. Defaults to `args => [config.cli, args]`. */ - publishCommand?: (args: string[]) => Command; + publishCommand?: (arguments_: string[]) => Command; /** CLI command which is expected to output the npm registry to use, e.g. `['npm', ['config', 'get', 'registry']]`. diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 529821a6..5655f31e 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -6,32 +6,32 @@ import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -const prerequisiteTasks = (input, pkg, options, pkgManager) => { - const isExternalRegistry = npm.isExternalRegistry(pkg); +const prerequisiteTasks = (input, package_, options, packageManager) => { + const isExternalRegistry = npm.isExternalRegistry(package_); let newVersion; const tasks = [ { title: 'Ping npm registry', - enabled: () => !pkg.private && !isExternalRegistry, + enabled: () => !package_.private && !isExternalRegistry, task: async () => npm.checkConnection(), }, { - title: `Check ${pkgManager.cli} version`, + title: `Check ${packageManager.cli} version`, async task() { - const {stdout: version} = await execa(pkgManager.cli, ['--version']); - util.validateEngineVersionSatisfies(pkgManager.cli, version); + const {stdout: version} = await execa(packageManager.cli, ['--version']); + util.validateEngineVersionSatisfies(packageManager.cli, version); }, }, { title: 'Verify user is authenticated', - enabled: () => process.env.NODE_ENV !== 'test' && !pkg.private, + enabled: () => process.env.NODE_ENV !== 'test' && !package_.private, async task() { const username = await npm.username({ - externalRegistry: isExternalRegistry ? pkg.publishConfig.registry : false, + externalRegistry: isExternalRegistry ? package_.publishConfig.registry : false, }); - const collaborators = await npm.collaborators(pkg); + const collaborators = await npm.collaborators(package_); if (!collaborators) { return; } @@ -56,13 +56,13 @@ const prerequisiteTasks = (input, pkg, options, pkgManager) => { task() { newVersion = input instanceof Version ? input - : new Version(pkg.version).setFrom(input); + : new Version(package_.version).setFrom(input); }, }, { title: 'Check for pre-release version', task() { - if (!pkg.private && newVersion.isPrerelease() && !options.tag) { + if (!package_.private && newVersion.isPrerelease() && !options.tag) { throw new Error('You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'); } }, @@ -72,7 +72,7 @@ const prerequisiteTasks = (input, pkg, options, pkgManager) => { async task() { await git.fetch(); - const tagPrefix = await util.getTagVersionPrefix(pkgManager); + const tagPrefix = await util.getTagVersionPrefix(packageManager); await git.verifyTagDoesNotExistOnRemote(`${tagPrefix}${newVersion}`); }, diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 7f09c9f5..5ddc80a8 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -3,12 +3,12 @@ import newGithubReleaseUrl from 'new-github-release-url'; import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; -const releaseTaskHelper = async (options, pkg, pkgManager) => { +const releaseTaskHelper = async (options, package_, packageManager) => { const newVersion = options.releaseDraftOnly - ? new Version(pkg.version) - : new Version(pkg.version).setFrom(options.version.toString(), {prereleasePrefix: await getPreReleasePrefix(pkgManager)}); + ? new Version(package_.version) + : new Version(package_.version).setFrom(options.version.toString(), {prereleasePrefix: await getPreReleasePrefix(packageManager)}); - const tag = await getTagVersionPrefix(pkgManager) + newVersion.toString(); + const tag = await getTagVersionPrefix(packageManager) + newVersion.toString(); const url = newGithubReleaseUrl({ repoUrl: options.repoUrl, diff --git a/source/ui.js b/source/ui.js index 115c08a8..b10f92c3 100644 --- a/source/ui.js +++ b/source/ui.js @@ -79,9 +79,9 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch }; }; -const checkNewFilesAndDependencies = async (pkg, rootDir) => { - const newFiles = await util.getNewFiles(rootDir); - const newDependencies = await util.getNewDependencies(pkg, rootDir); +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; @@ -123,26 +123,26 @@ const checkNewFilesAndDependencies = async (pkg, rootDir) => { /** @param {import('./cli-implementation.js').CLI['flags']} options -@param {{pkg: import('read-pkg').NormalizedPackageJson; rootDir: string}} context +@param {{package_: import('read-pkg').NormalizedPackageJson; rootDirectory: string}} context */ -const ui = async (options, {pkg, rootDir}) => { - const oldVersion = pkg.version; +const ui = async (options, {package_, rootDirectory}) => { + const oldVersion = package_.version; const extraBaseUrls = ['gitlab.com']; - const repoUrl = pkg.repository && githubUrlFromGit(pkg.repository.url, {extraBaseUrls}); + const repoUrl = package_.repository && githubUrlFromGit(package_.repository.url, {extraBaseUrls}); - const pkgManager = getPackageManagerConfig(rootDir, pkg); + const packageManager = getPackageManagerConfig(rootDirectory, package_); - if (pkgManager.throwOnExternalRegistry && npm.isExternalRegistry(pkg)) { - throw new Error(`External registry is not yet supported with ${pkgManager.id}.`); + if (packageManager.throwOnExternalRegistry && npm.isExternalRegistry(package_)) { + throw new Error(`External registry is not yet supported with ${packageManager.id}.`); } - const {stdout: registryUrl} = await execa(...pkgManager.getRegistryCommand); + const {stdout: registryUrl} = await execa(...packageManager.getRegistryCommand); const releaseBranch = options.branch; if (options.runPublish) { - await npm.checkIgnoreStrategy(pkg, rootDir); + await npm.checkIgnoreStrategy(package_, rootDirectory); - const answerIgnoredFiles = await checkNewFilesAndDependencies(pkg, rootDir); + const answerIgnoredFiles = await checkNewFilesAndDependencies(package_, rootDirectory); if (!answerIgnoredFiles) { return { ...options, @@ -152,13 +152,13 @@ const ui = async (options, {pkg, rootDir}) => { } if (options.releaseDraftOnly) { - console.log(`\nCreate a release draft on GitHub for ${chalk.bold.magenta(pkg.name)} ${chalk.dim(`(current: ${oldVersion})`)}\n`); + 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(pkgManager)}).format()})`) + ? 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(pkg.name)} ${versionText}\n`); + console.log(`\nPublish a new version of ${chalk.bold.magenta(package_.name)} ${versionText}\n`); } const useLatestTag = !options.releaseDraftOnly; @@ -209,15 +209,15 @@ const ui = async (options, {pkg, rootDir}) => { } if (options.availability.isUnknown) { - if (!isScoped(pkg.name)) { + 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(pkg.name) && options.runPublish, - message: `Failed to check availability of scoped repo name ${chalk.bold.magenta(pkg.name)}. Do you want to try and publish it anyway?`, + 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, }, }); @@ -236,22 +236,22 @@ const ui = async (options, {pkg, rootDir}) => { && !options.tag ); - const alreadyPublicScoped = pkgManager.id === 'yarn-berry' && options.runPublish && await util.getNpmPackageAccess(pkg.name) === 'public'; + 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(pkg.name) || !options.availability.isAvailable || options.availability.isUnknown || !options.runPublish) { + if (alreadyPublicScoped || !isScoped(package_.name) || !options.availability.isAvailable || options.availability.isUnknown || !options.runPublish) { return false; } - if (!pkg.publishConfig) { + if (!package_.publishConfig) { return true; } - return pkg.publishConfig.access !== 'restricted' && !npm.isExternalRegistry(pkg); + return package_.publishConfig.access !== 'restricted' && !npm.isExternalRegistry(package_); })(); const answers = await inquirer.prompt({ @@ -260,9 +260,9 @@ const ui = async (options, {pkg, rootDir}) => { message: 'Select SemVer increment or specify new version', pageSize: SEMVER_INCREMENTS.length + 2, choices: [ - ...SEMVER_INCREMENTS.map(inc => ({ // TODO: prerelease prefix here too - name: `${inc} ${new Version(oldVersion, inc).format()}`, - value: inc, + ...SEMVER_INCREMENTS.map(increment => ({ // TODO: prerelease prefix here too + name: `${increment} ${new Version(oldVersion, increment).format()}`, + value: increment, })), new inquirer.Separator(), { @@ -304,7 +304,7 @@ const ui = async (options, {pkg, rootDir}) => { message: 'How should this pre-release version be tagged in npm?', when: answers => needsPrereleaseTag(answers), async choices() { - const existingPrereleaseTags = await npm.prereleaseTags(pkg.name); + const existingPrereleaseTags = await npm.prereleaseTags(package_.name); return [ ...existingPrereleaseTags, @@ -335,7 +335,7 @@ const ui = async (options, {pkg, rootDir}) => { publishScoped: { type: 'confirm', when: needToAskForPublish, - message: `This scoped repo ${chalk.bold.magenta(pkg.name)} hasn't been published. Do you want to publish it publicly?`, + message: `This scoped repo ${chalk.bold.magenta(package_.name)} hasn't been published. Do you want to publish it publicly?`, default: false, }, }); diff --git a/source/util.js b/source/util.js index e0ea3e66..e19cc7c7 100644 --- a/source/util.js +++ b/source/util.js @@ -13,20 +13,20 @@ import Version from './version.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; -export const readPkg = async (packagePath = process.cwd()) => { +export const readPackage = async (packagePath = process.cwd()) => { const packageResult = await readPackageUp({cwd: packagePath}); if (!packageResult) { throw new Error('No `package.json` found. Make sure the current directory is a valid package.'); } - return {pkg: packageResult.packageJson, rootDir: path.dirname(packageResult.path)}; + return {package_: packageResult.packageJson, rootDirectory: path.dirname(packageResult.path)}; }; -const _npRootDir = fileURLToPath(new URL('..', import.meta.url)); +const _npRootDirectory = fileURLToPath(new URL('..', import.meta.url)); -// Re-define `npRootDir` for trailing slash consistency -export const {pkg: npPkg, rootDir: npRootDir} = await readPkg(_npRootDir); +// Re-define `npRootDirectory` for trailing slash consistency. +export const {package_: npPackage, rootDirectory: npRootDirectory} = await readPackage(_npRootDirectory); export const linkifyIssues = (url, message) => { if (!(url && terminalLink.isSupported)) { @@ -97,32 +97,32 @@ export const groupFilesInFolders = (files, groupingMinimumDepth = 1, groupingThr return chalk.reset(lines.join('\n')); }; -export const getNewFiles = async rootDir => { - const listNewFiles = await git.newFilesSinceLastRelease(rootDir); - const listPkgFiles = await npm.getFilesToBePacked(rootDir); +export const getNewFiles = async rootDirectory => { + const listNewFiles = await git.newFilesSinceLastRelease(rootDirectory); + const listPackageFiles = await npm.getFilesToBePacked(rootDirectory); return { - unpublished: listNewFiles.filter(file => !listPkgFiles.includes(file) && !file.startsWith('.git')), - firstTime: listNewFiles.filter(file => listPkgFiles.includes(file)), + unpublished: listNewFiles.filter(file => !listPackageFiles.includes(file) && !file.startsWith('.git')), + firstTime: listNewFiles.filter(file => listPackageFiles.includes(file)), }; }; -export const getNewDependencies = async (newPkg, rootDir) => { - let oldPkgFile; +export const getNewDependencies = async (newPackage, rootDirectory) => { + let oldPackageFile; try { - oldPkgFile = await git.readFileFromLastRelease(path.resolve(rootDir, 'package.json')); + oldPackageFile = await git.readFileFromLastRelease(path.resolve(rootDirectory, 'package.json')); } catch { // Handle first time publish - return Object.keys(newPkg.dependencies ?? {}); + return Object.keys(newPackage.dependencies ?? {}); } - const oldPkg = parsePackage(oldPkgFile); + const oldPackage = parsePackage(oldPackageFile); const newDependencies = []; - for (const dependency of Object.keys(newPkg.dependencies ?? {})) { - if (!oldPkg.dependencies?.[dependency]) { + for (const dependency of Object.keys(newPackage.dependencies ?? {})) { + if (!oldPackage.dependencies?.[dependency]) { newDependencies.push(dependency); } } @@ -144,7 +144,7 @@ export const getPreReleasePrefix = pMemoize(async config => { }); export const validateEngineVersionSatisfies = (engine, version) => { - const engineRange = npPkg.engines[engine]; + const engineRange = npPackage.engines[engine]; if (!new Version(version).satisfies(engineRange)) { throw new Error(`\`np\` requires ${engine} ${engineRange}`); } diff --git a/source/version.js b/source/version.js index 82b21ce4..064edfef 100644 --- a/source/version.js +++ b/source/version.js @@ -11,10 +11,10 @@ const SEMVER_INCREMENTS_LIST_LAST_OR = `${SEMVER_INCREMENTS.slice(0, -1).join(', /** @typedef {import('chalk').ColorName | import('chalk').ModifierName} ColorName */ /** @param {string} input @returns {input is SemVerIncrement} */ -const isSemVerIncrement = input => SEMVER_INCREMENTS.includes(input); +const isSemVersionIncrement = input => SEMVER_INCREMENTS.includes(input); /** @param {string} input */ -const isInvalidSemVerVersion = input => Boolean(!semver.valid(input)); +const isInvalidSemVersion = input => Boolean(!semver.valid(input)); /** Formats the first difference between two versions to the given `diffColor`. Useful for `prerelease` diffs. @@ -66,7 +66,7 @@ export default class Version { this.#trySetVersion(version); if (increment) { - if (!isSemVerIncrement(increment)) { + if (!isSemVersionIncrement(increment)) { throw new Error(`Increment ${increment} should be one of ${SEMVER_INCREMENTS_LIST_LAST_OR}.`); } @@ -86,10 +86,10 @@ export default class Version { this.#prereleasePrefix ??= prereleasePrefix; const previousVersion = this.toString(); - if (isSemVerIncrement(input)) { + if (isSemVersionIncrement(input)) { this.#version.inc(input, this.#prereleasePrefix); } else { - if (isInvalidSemVerVersion(input)) { + if (isInvalidSemVersion(input)) { throw new Error(`New version ${input} should either be one of ${SEMVER_INCREMENTS_LIST}, or a valid SemVer version.`); } diff --git a/test/_helpers/integration-test.d.ts b/test/_helpers/integration-test.d.ts index 5bbd4f81..2dc778fc 100644 --- a/test/_helpers/integration-test.d.ts +++ b/test/_helpers/integration-test.d.ts @@ -11,22 +11,22 @@ type Context = { }>; }; -type CommandsFnParameters = [{ +type CommandsFunctionParameters = [{ t: ExecutionContext; $$: Execa$; - temporaryDir: string; + temporaryDirectory: string; }]; -type AssertionsFnParameters = [{ +type AssertionsFunctionParameters = [{ t: ExecutionContext; testedModule: MockType; $$: Execa$; - temporaryDir: string; + temporaryDirectory: string; }]; export type CreateFixtureMacro = Macro<[ - commands: (...arguments_: CommandsFnParameters) => Promise, - assertions: (...arguments_: AssertionsFnParameters) => Promise, + commands: (...arguments_: CommandsFunctionParameters) => Promise, + assertions: (...arguments_: AssertionsFunctionParameters) => Promise, ], Context>; export function _createFixture(source: string): CreateFixtureMacro; diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js index 1e051589..f73bf6dc 100644 --- a/test/_helpers/integration-test.js +++ b/test/_helpers/integration-test.js @@ -7,13 +7,13 @@ import esmock from 'esmock'; import {$, execa} from 'execa'; import {temporaryDirectoryTask} from 'tempy'; -const createEmptyGitRepo = async ($$, temporaryDir) => { +const createEmptyGitRepo = async ($$, temporaryDirectory) => { const firstCommitMessage = '"init1"'; await $$`git init`; // `git tag` needs an initial commit - await fs.createFile(path.resolve(temporaryDir, 'temp')); + await fs.createFile(path.resolve(temporaryDirectory, 'temp')); await $$`git add temp`; await $$`git commit -m ${firstCommitMessage}`; await $$`git rm temp`; @@ -23,10 +23,10 @@ const createEmptyGitRepo = async ($$, temporaryDir) => { }; export const createIntegrationTest = async (t, assertions) => { - await temporaryDirectoryTask(async temporaryDir => { - const $$ = $({cwd: temporaryDir}); + await temporaryDirectoryTask(async temporaryDirectory => { + const $$ = $({cwd: temporaryDirectory}); - t.context.firstCommitMessage = await createEmptyGitRepo($$, temporaryDir); + t.context.firstCommitMessage = await createEmptyGitRepo($$, temporaryDirectory); // From https://stackoverflow.com/a/3357357/10292952 t.context.getCommitMessage = async sha => { @@ -34,7 +34,7 @@ export const createIntegrationTest = async (t, assertions) => { return commitMessage.trim(); }; - t.context.createFile = async (file, content = '') => fs.outputFile(path.resolve(temporaryDir, file), content); + t.context.createFile = async (file, content = '') => fs.outputFile(path.resolve(temporaryDirectory, file), content); t.context.commitNewFile = async () => { await t.context.createFile(`new-${crypto.randomUUID()}`); @@ -49,18 +49,20 @@ export const createIntegrationTest = async (t, assertions) => { }; }; - await assertions({$$, temporaryDir}); + await assertions({$$, temporaryDirectory}); }); }; export const _createFixture = source => test.macro(async (t, commands, assertions) => { - await createIntegrationTest(t, async ({$$, temporaryDir}) => { + await createIntegrationTest(t, async ({$$, temporaryDirectory}) => { const testedModule = await esmock(source, {}, { - 'node:process': {cwd: () => temporaryDir}, - execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + 'node:process': {cwd: () => temporaryDirectory}, + execa: {execa: async (...arguments_) => execa(...arguments_, {cwd: temporaryDirectory})}, }); - await commands({t, $$, temporaryDir}); - await assertions({t, testedModule, $$, temporaryDir}); + await commands({t, $$, temporaryDirectory}); + await assertions({ + t, testedModule, $$, temporaryDirectory, + }); }); }); diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js index 9a4c1d92..1af5d6ce 100644 --- a/test/_helpers/mock-inquirer.js +++ b/test/_helpers/mock-inquirer.js @@ -215,7 +215,7 @@ export const mockInquirer = async ({t, answers, mocks = {}}) => { }, { ...fixRelativeMocks(mocks), import: { - console: {log: (...args) => logs.push(...args)}, + console: {log: (...arguments_) => logs.push(...arguments_)}, }, }); diff --git a/test/_helpers/stub-execa.d.ts b/test/_helpers/stub-execa.d.ts index ccf04da6..0318a4ae 100644 --- a/test/_helpers/stub-execa.d.ts +++ b/test/_helpers/stub-execa.d.ts @@ -1,14 +1,14 @@ import type {Macro, ExecutionContext} from 'ava'; import type {ExecaReturnValue} from 'execa'; -type AssertionsFnParameters = [{ +type AssertionsFunctionParameters = [{ t: ExecutionContext; testedModule: MockType; }]; export type CreateFixtureMacro = Macro<[ commands: ExecaReturnValue[], - assertions: (...arguments_: AssertionsFnParameters) => Promise, + assertions: (...arguments_: AssertionsFunctionParameters) => Promise, ]>; export function _createFixture(source: string, importMeta: string): CreateFixtureMacro; diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js index f5c0973e..7132023d 100644 --- a/test/_helpers/stub-execa.js +++ b/test/_helpers/stub-execa.js @@ -17,14 +17,14 @@ const makeExecaStub = commands => { const stub = sinon.stub(); for (const result of commands) { - const [command, ...commandArgs] = result.command.split(' '); + const [command, ...commandArguments] = result.command.split(' '); const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); if (passes) { - stub.withArgs(command, commandArgs).resolves(result); + stub.withArgs(command, commandArguments).resolves(result); } else { - stub.withArgs(command, commandArgs).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message + stub.withArgs(command, commandArguments).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message } } @@ -36,9 +36,9 @@ const stubExeca = commands => { return { execa: { - async execa(...args) { - execaStub.resolves(execa(...args)); - return execaStub(...args); + async execa(...arguments_) { + execaStub.resolves(execa(...arguments_)); + return execaStub(...arguments_); }, }, }; diff --git a/test/_helpers/util.js b/test/_helpers/util.js index 60600052..b1f2c6dd 100644 --- a/test/_helpers/util.js +++ b/test/_helpers/util.js @@ -1,9 +1,9 @@ import {fileURLToPath} from 'node:url'; import path from 'node:path'; -export const runIfExists = async (func, ...args) => { - if (typeof func === 'function') { - await func(...args); +export const runIfExists = async (function_, ...arguments_) => { + if (typeof function_ === 'function') { + await function_(...arguments_); } }; diff --git a/test/_helpers/verify-cli.js b/test/_helpers/verify-cli.js index 9d3985a8..ebbed6b8 100644 --- a/test/_helpers/verify-cli.js +++ b/test/_helpers/verify-cli.js @@ -4,8 +4,8 @@ import {execa} from 'execa'; const trim = stdout => stdout.split('\n').map(line => line.trim()); -const _verifyCli = shouldPass => test.macro(async (t, binPath, args, expectedLines) => { - const {exitCode, stdout} = await execa(binPath, [args].flat(), {reject: false}); +const _verifyCli = shouldPass => test.macro(async (t, binaryPath, arguments_, expectedLines) => { + const {exitCode, stdout} = await execa(binaryPath, [arguments_].flat(), {reject: false}); const receivedLines = trim(stdout); t.deepEqual(receivedLines, expectedLines, 'CLI output different than expectations!'); diff --git a/test/cli.js b/test/cli.js index 2406bd82..35878746 100644 --- a/test/cli.js +++ b/test/cli.js @@ -1,9 +1,9 @@ import path from 'node:path'; import test from 'ava'; -import {npPkg, npRootDir as rootDir} from '../source/util.js'; +import {npPackage, npRootDirectory as rootDirectory} from '../source/util.js'; import {cliPasses} from './_helpers/verify-cli.js'; -const cli = path.resolve(rootDir, 'source', 'cli-implementation.js'); +const cli = path.resolve(rootDirectory, 'source', 'cli-implementation.js'); test('flags: --help', cliPasses, cli, '--help', [ '', @@ -41,4 +41,4 @@ test('flags: --help', cliPasses, cli, '--help', [ '', ]); -test('flags: --version', cliPasses, cli, '--version', [npPkg.version]); +test('flags: --version', cliPasses, cli, '--version', [npPackage.version]); diff --git a/test/config.js b/test/config.js index 5f2f6bd8..4bb07ebe 100644 --- a/test/config.js +++ b/test/config.js @@ -7,44 +7,44 @@ const testedModulePath = '../source/config.js'; const getFixture = fixture => path.resolve('test', 'fixtures', 'config', fixture); const getFixtures = fixtures => fixtures.map(fixture => getFixture(fixture)); -const getConfigsWhenGlobalBinaryIsUsed = async homedir => { - const pathsPkgDir = getFixtures(['pkg-dir', 'local1', 'local2', 'local3']); +const getConfigsWhenGlobalBinaryIsUsed = async homeDirectory => { + const pathsPackageDirectory = getFixtures(['pkg-dir', 'local1', 'local2', 'local3']); - const promises = pathsPkgDir.map(async pathPkgDir => { + const promises = pathsPackageDirectory.map(async pathPackageDirectory => { const getConfig = await esmock(testedModulePath, { 'is-installed-globally': true, - 'node:os': {homedir: () => homedir}, + 'node:os': {homedir: () => homeDirectory}, }); - return getConfig(pathPkgDir); + return getConfig(pathPackageDirectory); }); return Promise.all(promises); }; -const getConfigsWhenLocalBinaryIsUsed = async pathPkgDir => { - const homedirs = getFixtures(['homedir1', 'homedir2', 'homedir3']); +const getConfigsWhenLocalBinaryIsUsed = async pathPackageDirectory => { + const homeDirectories = getFixtures(['homedir1', 'homedir2', 'homedir3']); - const promises = homedirs.map(async homedir => { + const promises = homeDirectories.map(async homedir => { const getConfig = await esmock(testedModulePath, { 'is-installed-globally': false, 'node:os': {homedir: () => homedir}, }); - return getConfig(pathPkgDir); + return getConfig(pathPackageDirectory); }); return Promise.all(promises); }; -const useGlobalBinary = test.macro(async (t, homedir, source) => { - const configs = await getConfigsWhenGlobalBinaryIsUsed(getFixture(homedir)); +const useGlobalBinary = test.macro(async (t, homeDirectory, source) => { + const configs = await getConfigsWhenGlobalBinaryIsUsed(getFixture(homeDirectory)); for (const config of configs) { t.deepEqual(config, {source}); } }); -const useLocalBinary = test.macro(async (t, pkgDir, source) => { - const configs = await getConfigsWhenLocalBinaryIsUsed(getFixture(pkgDir)); +const useLocalBinary = test.macro(async (t, packageDirectory, source) => { + const configs = await getConfigsWhenLocalBinaryIsUsed(getFixture(packageDirectory)); for (const config of configs) { t.deepEqual(config, {source}); diff --git a/test/git-util/new-files-since-last-release.js b/test/git-util/new-files-since-last-release.js index 01ceb737..7cf68d65 100644 --- a/test/git-util/new-files-since-last-release.js +++ b/test/git-util/new-files-since-last-release.js @@ -10,8 +10,8 @@ test('returns files added since latest tag', createFixture, async ({t, $$}) => { await t.context.createFile('index.js'); await $$`git add .`; await $$`git commit -m "added"`; -}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { - const newFiles = await newFilesSinceLastRelease(temporaryDir); +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDirectory}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDirectory); t.deepEqual( newFiles.sort(), ['new', 'index.js'].sort(), @@ -20,8 +20,8 @@ test('returns files added since latest tag', createFixture, async ({t, $$}) => { test('no files', createFixture, async ({$$}) => { await $$`git tag v0.0.0`; -}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { - const newFiles = await newFilesSinceLastRelease(temporaryDir); +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDirectory}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDirectory); t.deepEqual(newFiles, []); }); @@ -30,8 +30,8 @@ test('uses ignoreWalker', createFixture, async ({t}) => { await t.context.createFile('package.json'); await t.context.createFile('package-lock.json'); await t.context.createFile('.gitignore', 'package-lock.json\n.git'); // ignoreWalker doesn't ignore `.git`: npm/ignore-walk#2 -}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDir}) => { - const newFiles = await newFilesSinceLastRelease(temporaryDir); +}, async ({t, testedModule: {newFilesSinceLastRelease}, temporaryDirectory}) => { + const newFiles = await newFilesSinceLastRelease(temporaryDirectory); t.deepEqual( newFiles.sort(), ['index.js', 'package.json', '.gitignore'].sort(), diff --git a/test/git-util/root.js b/test/git-util/root.js index 6689df70..b5bd3497 100644 --- a/test/git-util/root.js +++ b/test/git-util/root.js @@ -1,17 +1,17 @@ import test from 'ava'; import {_createFixture} from '../_helpers/integration-test.js'; -import {npRootDir} from '../../source/util.js'; +import {npRootDirectory} from '../../source/util.js'; import {root} from '../../source/git-util.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/git-util.js'); test('returns np root dir', async t => { - t.is(await root(), npRootDir); + t.is(await root(), npRootDirectory); }); test('returns root dir of temp dir', createFixture, () => { // -}, async ({t, testedModule: git, temporaryDir}) => { - t.is(await git.root(), temporaryDir); +}, async ({t, testedModule: git, temporaryDirectory}) => { + t.is(await git.root(), temporaryDirectory); }); diff --git a/test/index.js b/test/index.js index 7e3b3da2..01d22601 100644 --- a/test/index.js +++ b/test/index.js @@ -16,11 +16,11 @@ const defaultOptions = { renderer: 'silent', }; -const npPkgResult = await util.readPkg(); +const npPackageResult = await util.readPackage(); const npFails = test.macro(async (t, inputs, message) => { await t.throwsAsync( - Promise.all(inputs.map(input => np(input, defaultOptions, npPkgResult))), + Promise.all(inputs.map(input => np(input, defaultOptions, npPackageResult))), {message}, ); }); @@ -69,7 +69,7 @@ test('skip enabling 2FA if the package exists', async t => { isAvailable: false, isUnknown: false, }, - }, npPkgResult)); + }, npPackageResult)); t.true(enable2faStub.notCalled); }); @@ -99,7 +99,7 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { isUnknown: false, }, '2fa': false, - }, npPkgResult)); + }, npPackageResult)); t.true(enable2faStub.notCalled); }); diff --git a/test/npm/enable-2fa.js b/test/npm/enable-2fa.js index 59fa5203..e6d0dc2d 100644 --- a/test/npm/enable-2fa.js +++ b/test/npm/enable-2fa.js @@ -16,27 +16,27 @@ for (const {version, accessArgs} of npmVersionFixtures) { }]; test(`npm v${version} - no options`, createFixture, npmVersionCommand, - async ({t, testedModule: {getEnable2faArgs}}) => { + async ({t, testedModule: {getEnable2faArguments}}) => { t.deepEqual( - await getEnable2faArgs('np'), + await getEnable2faArguments('np'), [...accessArgs, 'np'], ); }, ); test(`npm v${version} - options, no otp`, createFixture, npmVersionCommand, - async ({t, testedModule: {getEnable2faArgs}}) => { + async ({t, testedModule: {getEnable2faArguments}}) => { t.deepEqual( - await getEnable2faArgs('np', {confirm: true}), + await getEnable2faArguments('np', {confirm: true}), [...accessArgs, 'np'], ); }, ); test(`npm v${version} - options, with otp`, createFixture, npmVersionCommand, - async ({t, testedModule: {getEnable2faArgs}}) => { + async ({t, testedModule: {getEnable2faArguments}}) => { t.deepEqual( - await getEnable2faArgs('np', {otp: '123456'}), + await getEnable2faArguments('np', {otp: '123456'}), [...accessArgs, 'np', '--otp', '123456'], ); }, diff --git a/test/npm/util/check-ignore-strategy.js b/test/npm/util/check-ignore-strategy.js index 1617eb85..000ec91e 100644 --- a/test/npm/util/check-ignore-strategy.js +++ b/test/npm/util/check-ignore-strategy.js @@ -9,13 +9,13 @@ const checkIgnoreStrategy = test.macro(async (t, {fixture = '', files, expected /** @type {import('../../../source/npm/util.js')} */ const {checkIgnoreStrategy} = await esmock('../../../source/npm/util.js', { - import: {console: {log: (...args) => output = args.join('')}}, // eslint-disable-line no-return-assign + import: {console: {log: (...arguments_) => output = arguments_.join('')}}, // eslint-disable-line no-return-assign }); - const fixtureDir = path.resolve('test/fixtures/files', fixture); - const pkg = files ? {files} : {}; + const fixtureDirectory = path.resolve('test/fixtures/files', fixture); + const package_ = files ? {files} : {}; - await checkIgnoreStrategy(pkg, fixtureDir); + await checkIgnoreStrategy(package_, fixtureDirectory); output = stripAnsi(output).trim(); t.is(output, expected); diff --git a/test/npm/util/collaborators.js b/test/npm/util/collaborators.js index 776f5a03..074e4195 100644 --- a/test/npm/util/collaborators.js +++ b/test/npm/util/collaborators.js @@ -6,7 +6,7 @@ import * as npm from '../../../source/npm/util.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); -test('pkg.name not a string', async t => { +test('package.name not a string', async t => { await t.throwsAsync( npm.collaborators({name: 1}), {message: 'Expected argument to be of type `string` but received type `number`'}, diff --git a/test/npm/util/is-package-name-available.js b/test/npm/util/is-package-name-available.js index 084f45d6..ecb28a6c 100644 --- a/test/npm/util/is-package-name-available.js +++ b/test/npm/util/is-package-name-available.js @@ -10,11 +10,11 @@ const createFixture = test.macro(async (t, {name = 'foo', npmNameStub, expected, 'npm-name': npmNameStub, }); - const pkg = isExternalRegistry + const package_ = isExternalRegistry ? {name, publishConfig: {registry: externalRegistry}} : {name}; - const availability = await npm.isPackageNameAvailable(pkg); + const availability = await npm.isPackageNameAvailable(package_); t.like(availability, expected); }); diff --git a/test/npm/util/packed-files.js b/test/npm/util/packed-files.js index a3034fb9..da2d1f6a 100644 --- a/test/npm/util/packed-files.js +++ b/test/npm/util/packed-files.js @@ -6,12 +6,12 @@ import {runIfExists} from '../../_helpers/util.js'; const getFixture = name => path.resolve('test', 'fixtures', 'files', name); const verifyPackedFiles = test.macro(async (t, fixture, expectedFiles, {before, after} = {}) => { - const fixtureDir = getFixture(fixture); + const fixtureDirectory = getFixture(fixture); - await runIfExists(before, fixtureDir); - t.teardown(async () => runIfExists(after, fixtureDir)); + await runIfExists(before, fixtureDirectory); + t.teardown(async () => runIfExists(after, fixtureDirectory)); - const files = await getFilesToBePacked(fixtureDir); + const files = await getFilesToBePacked(fixtureDirectory); t.deepEqual(files.sort(), [...expectedFiles, 'package.json'].sort(), 'Files different from expectations!'); }); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index b7480912..a92ae869 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -2,7 +2,7 @@ import process from 'node:process'; import test from 'ava'; import {npmConfig, yarnConfig} from '../../source/package-manager/configs.js'; import actualPrerequisiteTasks from '../../source/prerequisite-tasks.js'; -import {npPkg} from '../../source/util.js'; +import {npPackage} from '../../source/util.js'; import {SilentRenderer} from '../_helpers/listr-renderer.js'; import {_createFixture} from '../_helpers/stub-execa.js'; import {run, assertTaskFailed, assertTaskDisabled} from '../_helpers/listr.js'; @@ -61,7 +61,7 @@ test.serial('should fail when npm version does not match range in `package.json` stdout: '', }, ], async ({t, testedModule: prerequisiteTasks}) => { - const depRange = npPkg.engines.npm; + const depRange = npPackage.engines.npm; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), @@ -81,7 +81,7 @@ test.serial('should fail when yarn version does not match range in `package.json stdout: '', }, ], async ({t, testedModule: prerequisiteTasks}) => { - const depRange = npPkg.engines.yarn; + const depRange = npPackage.engines.yarn; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, yarnConfig)), @@ -156,7 +156,7 @@ test.serial('should fail when git version does not match range in `package.json` command: 'git version', stdout: 'git version 1.0.0', }], async ({t, testedModule: prerequisiteTasks}) => { - const depRange = npPkg.engines.git; + const depRange = npPackage.engines.git; await t.throwsAsync( run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), diff --git a/test/ui/new-files-dependencies.d.ts b/test/ui/new-files-dependencies.d.ts index 10f1387e..b4920e51 100644 --- a/test/ui/new-files-dependencies.d.ts +++ b/test/ui/new-files-dependencies.d.ts @@ -5,7 +5,7 @@ type Context = { createFile: (file: string, content?: string) => Promise; }; -type CommandsFnParameters = [{ +type CommandsFunctionParameters = [{ t: ExecutionContext; $$: Execa$; temporaryDir: string; @@ -19,7 +19,7 @@ type Expected = { dependencies: ListItem[]; }; -type AssertionsFnParameters = [{ +type AssertionsFunctionParameters = [{ t: ExecutionContext; $$: Execa$; temporaryDir: string; @@ -27,8 +27,8 @@ type AssertionsFnParameters = [{ }]; export type CreateFixtureMacro = Macro<[ - pkg: PackageJson, - commands: (...arguments_: CommandsFnParameters) => Promise, + package_: PackageJson, + commands: (...arguments_: CommandsFunctionParameters) => Promise, expected: Expected, - assertions: (...arguments_: AssertionsFnParameters) => Promise, + assertions: (...arguments_: AssertionsFunctionParameters) => Promise, ], Context>; diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index fc5eb792..0742dc43 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -23,34 +23,36 @@ const checkFirstTimeFiles = checkLines('The following new files will be publishe const checkNewDependencies = checkLines('The following new dependencies will be part of your published package:'); /** @type {import('./new-files-dependencies.d.ts').CreateFixtureMacro} */ -const createFixture = test.macro(async (t, pkg, commands, expected) => { - await createIntegrationTest(t, async ({$$, temporaryDir}) => { - pkg = { +const createFixture = test.macro(async (t, package_, commands, expected) => { + await createIntegrationTest(t, async ({$$, temporaryDirectory}) => { + package_ = { name: '@np/foo', version: '0.0.0', dependencies: {}, - ...pkg, + ...package_, }; - await updatePackage(temporaryDir, pkg); + await updatePackage(temporaryDirectory, package_); await $$`git add .`; await $$`git commit -m "added"`; await $$`git tag v0.0.0`; - await commands({t, $$, temporaryDir}); - pkg = await readPackage({cwd: temporaryDir}); - - const {ui, logs: logsArray} = await mockInquirer({t, answers: {confirm: {confirm: false}}, mocks: { - './npm/util.js': { - checkIgnoreStrategy: sinon.stub().resolves(), + await commands({t, $$, temporaryDirectory}); + package_ = await readPackage({cwd: temporaryDirectory}); + + const {ui, logs: logsArray} = await mockInquirer({ + t, answers: {confirm: {confirm: false}}, mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + }, + 'node:process': {cwd: () => temporaryDirectory}, + execa: {execa: async (...arguments_) => execa(...arguments_, {cwd: temporaryDirectory})}, + 'is-interactive': () => false, }, - 'node:process': {cwd: () => temporaryDir}, - execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, - 'is-interactive': () => false, - }}); + }); - await ui({runPublish: true, version: 'major', yarn: false}, {pkg, rootDir: temporaryDir}); + await ui({runPublish: true, version: 'major', yarn: false}, {package_, rootDirectory: temporaryDirectory}); const logs = logsArray.join('').split('\n').map(log => stripAnsi(log)); const {unpublished, firstTime, dependencies} = expected; @@ -71,7 +73,7 @@ const createFixture = test.macro(async (t, pkg, commands, expected) => { if (!assertions.passed) { t.log('logs:', logs); - t.log('pkg:', pkg); + t.log('package:', package_); t.log('expected:', expected); } @@ -92,11 +94,11 @@ test('unpublished and first time', createFixture, {files: ['*.js']}, async ({t, await $$`git commit -m "added"`; }, {unpublished: ['- new'], firstTime: ['- index.js']}); -test('unpublished and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { +test('unpublished and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDirectory}) => { await t.context.createFile('new'); await $$`git add .`; await $$`git commit -m "added"`; - await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); }, {unpublished: ['- new'], dependencies: ['- cat-names']}); test('first time', createFixture, {}, async ({t, $$}) => { @@ -105,22 +107,22 @@ test('first time', createFixture, {}, async ({t, $$}) => { await $$`git commit -m "added"`; }, {firstTime: ['- new']}); -test('first time and dependencies', createFixture, {}, async ({t, $$, temporaryDir}) => { +test('first time and dependencies', createFixture, {}, async ({t, $$, temporaryDirectory}) => { await t.context.createFile('new'); await $$`git add .`; await $$`git commit -m "added"`; - await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); }, {firstTime: ['- new'], dependencies: ['- cat-names']}); -test('dependencies', createFixture, {dependencies: {'dog-names': '^2.1.0'}}, async ({temporaryDir}) => { - await removePackageDependencies(temporaryDir, ['dog-names']); - await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); +test('dependencies', createFixture, {dependencies: {'dog-names': '^2.1.0'}}, async ({temporaryDirectory}) => { + await removePackageDependencies(temporaryDirectory, ['dog-names']); + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); }, {dependencies: ['- cat-names']}); -test('unpublished and first time and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDir}) => { +test('unpublished and first time and dependencies', createFixture, {files: ['*.js']}, async ({t, $$, temporaryDirectory}) => { await t.context.createFile('new'); await t.context.createFile('index.js'); await $$`git add .`; await $$`git commit -m "added"`; - await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); }, {unpublished: ['- new'], firstTime: ['- index.js'], dependencies: ['- cat-names']}); diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js index 1cf6acdc..77e1dedf 100644 --- a/test/ui/prompts/tags.js +++ b/test/ui/prompts/tags.js @@ -1,32 +1,34 @@ import test from 'ava'; import sinon from 'sinon'; -import {npPkg} from '../../../source/util.js'; +import {npPackage} from '../../../source/util.js'; import {mockInquirer} from '../../_helpers/mock-inquirer.js'; const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { - const {ui, logs} = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { - './npm/util.js': { - checkIgnoreStrategy: sinon.stub().resolves(), - prereleaseTags: sinon.stub().resolves(tags), - }, - './util.js': { - getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), - getNewDependencies: sinon.stub().resolves([]), - }, - './git-util.js': { - latestTagOrFirstCommit: sinon.stub().resolves(`v${npPkg.version}`), - commitLogFromRevision: sinon.stub().resolves(''), - }, - './package-manager/index.js': { - findLockfile: sinon.stub().resolves(undefined), + const {ui, logs} = await mockInquirer({ + t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + prereleaseTags: sinon.stub().resolves(tags), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves(`v${npPackage.version}`), + commitLogFromRevision: sinon.stub().resolves(''), + }, + './package-manager/index.js': { + findLockfile: sinon.stub().resolves(undefined), + }, }, - }}); + }); const results = await ui({ runPublish: true, availability: {}, }, { - pkg: { + package_: { name: 'foo', version, files: ['*'], diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index 8cb125e9..03f33612 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -3,25 +3,27 @@ import sinon from 'sinon'; import {mockInquirer} from '../../_helpers/mock-inquirer.js'; const testUi = test.macro(async (t, {version, answers}, assertions) => { - const {ui, logs} = await mockInquirer({t, answers: {confirm: true, ...answers}, mocks: { - './npm/util.js': { - checkIgnoreStrategy: sinon.stub().resolves(), - }, - './util.js': { - getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), - getNewDependencies: sinon.stub().resolves([]), - }, - './git-util.js': { - latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), - commitLogFromRevision: sinon.stub().resolves(''), + const {ui, logs} = await mockInquirer({ + t, answers: {confirm: true, ...answers}, mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves(''), + }, }, - }}); + }); const results = await ui({ runPublish: false, availability: {}, }, { - pkg: { + package_: { name: 'foo', version, files: ['*'], diff --git a/test/util/get-new-dependencies.js b/test/util/get-new-dependencies.js index 07f39769..bff15cdc 100644 --- a/test/util/get-new-dependencies.js +++ b/test/util/get-new-dependencies.js @@ -6,39 +6,39 @@ import {_createFixture} from '../_helpers/integration-test.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/util.js'); -test('reports new dependencies since last release', createFixture, async ({$$, temporaryDir}) => { - await updatePackage(temporaryDir, {dependencies: {'dog-names': '^2.1.0'}}); +test('reports new dependencies since last release', createFixture, async ({$$, temporaryDirectory}) => { + await updatePackage(temporaryDirectory, {dependencies: {'dog-names': '^2.1.0'}}); await $$`git add .`; await $$`git commit -m "added"`; await $$`git tag v0.0.0`; - await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); -}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { - const pkg = await readPackage({cwd: temporaryDir}); + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDirectory}) => { + const package_ = await readPackage({cwd: temporaryDirectory}); t.deepEqual( - await getNewDependencies(pkg, temporaryDir), + await getNewDependencies(package_, temporaryDirectory), ['cat-names'], ); }); -test('handles first time publish (no package.json in last release)', createFixture, async ({temporaryDir}) => { - await updatePackage(temporaryDir, {dependencies: {'cat-names': '^3.1.0'}}); -}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { - const pkg = await readPackage({cwd: temporaryDir}); +test('handles first time publish (no package.json in last release)', createFixture, async ({temporaryDirectory}) => { + await updatePackage(temporaryDirectory, {dependencies: {'cat-names': '^3.1.0'}}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDirectory}) => { + const package_ = await readPackage({cwd: temporaryDirectory}); t.deepEqual( - await getNewDependencies(pkg, temporaryDir), + await getNewDependencies(package_, temporaryDirectory), ['cat-names'], ); }); -test('handles first time publish (no package.json in last release) - no deps', createFixture, async ({temporaryDir}) => { - await updatePackage(temporaryDir, {name: '@np/foo'}); -}, async ({t, testedModule: {getNewDependencies}, temporaryDir}) => { - const pkg = await readPackage({cwd: temporaryDir}); +test('handles first time publish (no package.json in last release) - no deps', createFixture, async ({temporaryDirectory}) => { + await updatePackage(temporaryDirectory, {name: '@np/foo'}); +}, async ({t, testedModule: {getNewDependencies}, temporaryDirectory}) => { + const package_ = await readPackage({cwd: temporaryDirectory}); t.deepEqual( - await getNewDependencies(pkg, temporaryDir), + await getNewDependencies(package_, temporaryDirectory), [], ); }); diff --git a/test/util/get-new-files.js b/test/util/get-new-files.js index 3b13055d..37f3bb3e 100644 --- a/test/util/get-new-files.js +++ b/test/util/get-new-files.js @@ -6,26 +6,26 @@ import {writePackage} from 'write-package'; import {createIntegrationTest} from '../_helpers/integration-test.js'; const createNewFilesFixture = test.macro(async (t, input, commands) => { - const {pkgFiles, expected: {unpublished, firstTime}} = input; + const {packageFiles, expected: {unpublished, firstTime}} = input; - await createIntegrationTest(t, async ({$$, temporaryDir}) => { + await createIntegrationTest(t, async ({$$, temporaryDirectory}) => { /** @type {import('../../source/util.js')} */ const {getNewFiles} = await esmock('../../source/util.js', {}, { - 'node:process': {cwd: () => temporaryDir}, - execa: {execa: async (...args) => execa(...args, {cwd: temporaryDir})}, + 'node:process': {cwd: () => temporaryDirectory}, + execa: {execa: async (...arguments_) => execa(...arguments_, {cwd: temporaryDirectory})}, }); - await commands({t, $$, temporaryDir}); + await commands({t, $$, temporaryDirectory}); - await writePackage(temporaryDir, { + await writePackage(temporaryDirectory, { name: 'foo', version: '0.0.0', - ...pkgFiles.length > 0 ? {files: pkgFiles} : {}, + ...packageFiles.length > 0 ? {files: packageFiles} : {}, }); const assertions = await t.try(async tt => { tt.deepEqual( - await getNewFiles(temporaryDir), + await getNewFiles(temporaryDirectory), {unpublished, firstTime}, ); }); @@ -39,7 +39,7 @@ const createNewFilesFixture = test.macro(async (t, input, commands) => { }); test('files to package with tags added', createNewFilesFixture, { - pkgFiles: ['*.js'], + packageFiles: ['*.js'], expected: { unpublished: ['new'], firstTime: ['index.js'], @@ -53,7 +53,7 @@ test('files to package with tags added', createNewFilesFixture, { }); test('file `new` to package without tags added', createNewFilesFixture, { - pkgFiles: ['index.js'], + packageFiles: ['index.js'], expected: { unpublished: ['new'], firstTime: ['index.js', 'package.json'], @@ -69,7 +69,7 @@ test('file `new` to package without tags added', createNewFilesFixture, { const filePath2 = path.join(longPath, 'file2'); test('files with long pathnames added', createNewFilesFixture, { - pkgFiles: ['*.js'], + packageFiles: ['*.js'], expected: { unpublished: [filePath1, filePath2], firstTime: [], @@ -84,7 +84,7 @@ test('file `new` to package without tags added', createNewFilesFixture, { })(); test('no new files added', createNewFilesFixture, { - pkgFiles: [], + packageFiles: [], expected: { unpublished: [], firstTime: [], @@ -94,7 +94,7 @@ test('no new files added', createNewFilesFixture, { }); test('ignores .git and .github files', createNewFilesFixture, { - pkgFiles: ['*.js'], + packageFiles: ['*.js'], expected: { unpublished: [], firstTime: ['index.js'], diff --git a/test/util/get-pre-release-prefix.js b/test/util/get-pre-release-prefix.js index f99b2afc..84cbedba 100644 --- a/test/util/get-pre-release-prefix.js +++ b/test/util/get-pre-release-prefix.js @@ -50,10 +50,12 @@ test('returns empty string if not set - yarn', createFixture, [{ test('no options passed', async t => { await t.throwsAsync( originalGetPreReleasePrefix(), - {message: stripIndent` + { + message: stripIndent` Expected argument to be of type \`object\` but received type \`undefined\` Expected object to have keys \`["cli"]\` - `}, + `, + }, ); await t.throwsAsync( diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js index db714847..b14ba9e5 100644 --- a/test/util/get-tag-version-prefix.js +++ b/test/util/get-tag-version-prefix.js @@ -40,10 +40,12 @@ test('defaults to "v" when command fails', createFixture, [{ test('no options passed', async t => { await t.throwsAsync( originalGetTagVersionPrefix(), - {message: stripIndent` + { + message: stripIndent` Expected argument to be of type \`object\` but received type \`undefined\` Expected object to have keys \`["tagVersionPrefixCommand"]\` - `}, + `, + }, ); await t.throwsAsync( diff --git a/test/util/read-pkg.js b/test/util/read-pkg.js index e9dd49c8..a508ef30 100644 --- a/test/util/read-pkg.js +++ b/test/util/read-pkg.js @@ -3,47 +3,47 @@ import path from 'node:path'; import test from 'ava'; import esmock from 'esmock'; import {temporaryDirectory} from 'tempy'; -import {readPkg, npPkg, npRootDir} from '../../source/util.js'; +import {readPackage, npPackage, npRootDirectory} from '../../source/util.js'; -const rootDir = fileURLToPath(new URL('../..', import.meta.url)).slice(0, -1); +const rootDirectory = fileURLToPath(new URL('../..', import.meta.url)).slice(0, -1); test('without packagePath returns np package.json', async t => { - const {pkg, rootDir: pkgDir} = await readPkg(); + const {package_, rootDirectory: packageDirectory} = await readPackage(); - t.is(pkg.name, 'np'); - t.is(pkgDir, rootDir); + t.is(package_.name, 'np'); + t.is(packageDirectory, rootDirectory); }); test('with packagePath', async t => { - const fixtureDir = path.resolve(rootDir, 'test/fixtures/files/one-file'); - const {pkg, rootDir: pkgDir} = await readPkg(fixtureDir); + const fixtureDirectory = path.resolve(rootDirectory, 'test/fixtures/files/one-file'); + const {package_, rootDirectory: packageDirectory} = await readPackage(fixtureDirectory); - t.is(pkg.name, 'foo'); - t.is(pkgDir, fixtureDir); + t.is(package_.name, 'foo'); + t.is(packageDirectory, fixtureDirectory); }); test('no package.json', async t => { await t.throwsAsync( - readPkg(temporaryDirectory()), + readPackage(temporaryDirectory()), {message: 'No `package.json` found. Make sure the current directory is a valid package.'}, ); }); -test('npPkg', t => { - t.is(npPkg.name, 'np'); +test('npPackage', t => { + t.is(npPackage.name, 'np'); }); -test('npRootDir', t => { - t.is(npRootDir, rootDir); +test('npRootDirectory', t => { + t.is(npRootDirectory, rootDirectory); }); -test('npRootDir is correct when process.cwd is different', async t => { - const temporaryDir = temporaryDirectory(); +test('npRootDirectory is correct when process.cwd is different', async t => { + const cwd = temporaryDirectory(); /** @type {import('../../source/util.js')} */ const util = await esmock('../../source/util.js', {}, { - 'node:process': {cwd: temporaryDir}, + 'node:process': {cwd}, }); - t.is(util.npRootDir, rootDir); + t.is(util.npRootDirectory, rootDirectory); }); diff --git a/test/util/validate-engine-version-satisfies.js b/test/util/validate-engine-version-satisfies.js index cb9a5d21..9dc575e5 100644 --- a/test/util/validate-engine-version-satisfies.js +++ b/test/util/validate-engine-version-satisfies.js @@ -1,8 +1,8 @@ import test from 'ava'; -import {validateEngineVersionSatisfies, npPkg} from '../../source/util.js'; +import {validateEngineVersionSatisfies, npPackage} from '../../source/util.js'; const testEngineRanges = test.macro((t, engine, {above, below}) => { - const range = npPkg.engines[engine]; + const range = npPackage.engines[engine]; t.notThrows( () => validateEngineVersionSatisfies(engine, above), // Above minimum From 4bc37b04ef304c9eb06065541f252a0f4e6fd63e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 26 Feb 2024 21:11:13 +0700 Subject: [PATCH 080/180] 10.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a1edf0bc..c48b45b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "9.2.0", + "version": "10.0.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 5d4645f8a1c915aee17023e65301a74d45e4c86b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 27 Feb 2024 00:02:27 +0700 Subject: [PATCH 081/180] Fix CI --- test/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index.js b/test/index.js index 01d22601..b429a536 100644 --- a/test/index.js +++ b/test/index.js @@ -31,7 +31,7 @@ test('version is invalid', npFails, ); test('version is pre-release', npFails, - ['premajor', 'preminor', 'prepatch', 'prerelease', '10.0.0-0', '10.0.0-beta'], + ['premajor', 'preminor', 'prepatch', 'prerelease', '100.0.0-0', '100.0.0-beta'], 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag', ); From 15b4a7f784fd021245b1602b421f331c3c1b201d Mon Sep 17 00:00:00 2001 From: Tommy Date: Thu, 29 Feb 2024 23:16:34 -0600 Subject: [PATCH 082/180] Drop `ow` dependency (#733) --- package.json | 1 - source/npm/util.js | 5 ++--- source/util.js | 11 ++++++++--- test/npm/util/collaborators.js | 2 +- test/npm/util/prerelease-tags.js | 2 +- test/util/get-pre-release-prefix.js | 10 ++-------- test/util/get-tag-version-prefix.js | 10 ++-------- 7 files changed, 16 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index c48b45b3..dd7ac87f 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "npm-name": "^8.0.0", "onetime": "^7.0.0", "open": "^10.0.4", - "ow": "^1.1.1", "p-memoize": "^7.1.1", "p-timeout": "^6.1.2", "path-exists": "^5.0.0", diff --git a/source/npm/util.js b/source/npm/util.js index e72177e2..c1a6e2c5 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -2,7 +2,6 @@ import path from 'node:path'; import {pathExists} from 'path-exists'; import {execa} from 'execa'; import pTimeout from 'p-timeout'; -import ow from 'ow'; import npmName from 'npm-name'; import chalk from 'chalk-template'; import * as util from '../util.js'; @@ -48,7 +47,7 @@ export const isExternalRegistry = package_ => typeof package_.publishConfig?.reg export const collaborators = async package_ => { const packageName = package_.name; - ow(packageName, ow.string); + util.assert(typeof packageName === 'string', 'Package name is required'); const arguments_ = ['access', 'list', 'collaborators', packageName, '--json']; @@ -70,7 +69,7 @@ export const collaborators = async package_ => { }; export const prereleaseTags = async packageName => { - ow(packageName, ow.string); + util.assert(typeof packageName === 'string', 'Package name is required'); let tags = []; try { diff --git a/source/util.js b/source/util.js index e19cc7c7..229d6b10 100644 --- a/source/util.js +++ b/source/util.js @@ -7,12 +7,17 @@ import issueRegex from 'issue-regex'; import terminalLink from 'terminal-link'; import {execa} from 'execa'; import pMemoize from 'p-memoize'; -import ow from 'ow'; import chalk from 'chalk'; import Version from './version.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; +export const assert = (condition, message) => { + if (!condition) { + throw new Error(message); + } +}; + export const readPackage = async (packagePath = process.cwd()) => { const packageResult = await readPackageUp({cwd: packagePath}); @@ -62,7 +67,7 @@ export const linkifyCommitRange = (url, commitRange) => { /** @type {(config: import('./package-manager/types.js').PackageManagerConfig) => Promise} */ export const getTagVersionPrefix = pMemoize(async config => { - ow(config, ow.object.hasKeys('tagVersionPrefixCommand')); + assert(config && Object.hasOwn(config, 'tagVersionPrefixCommand'), 'Config is missing key `tagVersionPrefixCommand`'); try { const {stdout} = await execa(...config.tagVersionPrefixCommand); @@ -132,7 +137,7 @@ export const getNewDependencies = async (newPackage, rootDirectory) => { /** @type {(config: import('./package-manager/types.js').PackageManagerConfig) => Promise} */ export const getPreReleasePrefix = pMemoize(async config => { - ow(config, ow.object.hasKeys('cli')); + assert(config && Object.hasOwn(config, 'cli'), 'Config is missing key `cli`'); try { const {stdout} = await execa(config.cli, ['config', 'get', 'preid']); diff --git a/test/npm/util/collaborators.js b/test/npm/util/collaborators.js index 074e4195..8afffd28 100644 --- a/test/npm/util/collaborators.js +++ b/test/npm/util/collaborators.js @@ -9,7 +9,7 @@ const createFixture = _createFixture('../../../source/npm/util.js', import.meta. test('package.name not a string', async t => { await t.throwsAsync( npm.collaborators({name: 1}), - {message: 'Expected argument to be of type `string` but received type `number`'}, + {message: 'Package name is required'}, ); }); diff --git a/test/npm/util/prerelease-tags.js b/test/npm/util/prerelease-tags.js index a620cfd9..6424e414 100644 --- a/test/npm/util/prerelease-tags.js +++ b/test/npm/util/prerelease-tags.js @@ -9,7 +9,7 @@ const createFixture = _createFixture('../../../source/npm/util.js', import.meta. test('packageName not a string', async t => { await t.throwsAsync( npm.prereleaseTags(1), - {message: 'Expected argument to be of type `string` but received type `number`'}, + {message: 'Package name is required'}, ); }); diff --git a/test/util/get-pre-release-prefix.js b/test/util/get-pre-release-prefix.js index 84cbedba..0a69d8d8 100644 --- a/test/util/get-pre-release-prefix.js +++ b/test/util/get-pre-release-prefix.js @@ -1,6 +1,5 @@ import process from 'node:process'; import test from 'ava'; -import {stripIndent} from 'common-tags'; import {_createFixture} from '../_helpers/stub-execa.js'; import {getPreReleasePrefix as originalGetPreReleasePrefix} from '../../source/util.js'; @@ -50,17 +49,12 @@ test('returns empty string if not set - yarn', createFixture, [{ test('no options passed', async t => { await t.throwsAsync( originalGetPreReleasePrefix(), - { - message: stripIndent` - Expected argument to be of type \`object\` but received type \`undefined\` - Expected object to have keys \`["cli"]\` - `, - }, + {message: 'Config is missing key `cli`'}, ); await t.throwsAsync( originalGetPreReleasePrefix({}), - {message: 'Expected object to have keys `["cli"]`'}, + {message: 'Config is missing key `cli`'}, ); }); diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js index b14ba9e5..90bb1e28 100644 --- a/test/util/get-tag-version-prefix.js +++ b/test/util/get-tag-version-prefix.js @@ -1,5 +1,4 @@ import test from 'ava'; -import {stripIndent} from 'common-tags'; import {_createFixture} from '../_helpers/stub-execa.js'; import {getTagVersionPrefix as originalGetTagVersionPrefix} from '../../source/util.js'; import {npmConfig, yarnConfig} from '../../source/package-manager/configs.js'; @@ -40,16 +39,11 @@ test('defaults to "v" when command fails', createFixture, [{ test('no options passed', async t => { await t.throwsAsync( originalGetTagVersionPrefix(), - { - message: stripIndent` - Expected argument to be of type \`object\` but received type \`undefined\` - Expected object to have keys \`["tagVersionPrefixCommand"]\` - `, - }, + {message: 'Config is missing key `tagVersionPrefixCommand`'}, ); await t.throwsAsync( originalGetTagVersionPrefix({}), - {message: 'Expected object to have keys `["tagVersionPrefixCommand"]`'}, + {message: 'Config is missing key `tagVersionPrefixCommand`'}, ); }); From aa03498304f1378acd409aa7f1a9474b6ba89e7e Mon Sep 17 00:00:00 2001 From: Tommy Date: Thu, 29 Feb 2024 23:20:32 -0600 Subject: [PATCH 083/180] Update dependencies (#734) --- .github/workflows/main.yml | 2 + package.json | 12 +- source/cli-implementation.js | 1 - source/index.js | 1 - .../verify-remote-history-is-clean.js | 4 +- test/npm/util/collaborators.js | 114 ++++++++---------- 6 files changed, 58 insertions(+), 76 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2a7831da..87a31599 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,6 +10,8 @@ jobs: fail-fast: false matrix: node-version: + - 21 + - 20 - 18 steps: - uses: actions/checkout@v4 diff --git a/package.json b/package.json index dd7ac87f..444fde7f 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "exit-hook": "^4.0.0", "github-url-from-git": "^1.5.0", "hosted-git-info": "^7.0.1", - "ignore-walk": "^6.0.3", + "ignore-walk": "^6.0.4", "import-local": "^3.1.0", "inquirer": "^9.2.15", "is-installed-globally": "^1.0.0", @@ -52,7 +52,7 @@ "listr": "^0.14.3", "listr-input": "^0.2.1", "log-symbols": "^6.0.0", - "meow": "^13.1.0", + "meow": "^13.2.0", "new-github-release-url": "^2.0.0", "npm-name": "^8.0.0", "onetime": "^7.0.0", @@ -70,12 +70,12 @@ "update-notifier": "^7.0.0" }, "devDependencies": { - "@sindresorhus/is": "^6.1.0", + "@sindresorhus/is": "^6.2.0", "@types/semver": "^7.5.8", - "ava": "^5.3.1", + "ava": "^6.1.2", "common-tags": "^1.8.2", - "esmock": "^2.6.3", - "fs-extra": "^11.1.1", + "esmock": "^2.6.4", + "fs-extra": "^11.2.0", "map-obj": "^5.0.2", "sinon": "^17.0.1", "strip-ansi": "^7.1.0", diff --git a/source/cli-implementation.js b/source/cli-implementation.js index a6b3fd6b..6daa24c9 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -126,7 +126,6 @@ export async function getOptions() { const runPublish = !flags.releaseDraftOnly && flags.publish && !package_.private; - // TODO: does this need to run if `runPublish` is false? const availability = runPublish ? await npm.isPackageNameAvailable(package_) : { isAvailable: false, isUnknown: false, diff --git a/source/index.js b/source/index.js index 833b2032..e676bb9f 100644 --- a/source/index.js +++ b/source/index.js @@ -239,7 +239,6 @@ const np = async (input = 'patch', options, {package_, rootDirectory}) => { return '[Preview] GitHub Releases draft will not be opened in preview mode.'; } }, - // TODO: parse version outside of index task: () => releaseTaskHelper(options, package_, packageManager), }] : [], ], { diff --git a/test/git-util/verify-remote-history-is-clean.js b/test/git-util/verify-remote-history-is-clean.js index 14fd8ef1..c5b10559 100644 --- a/test/git-util/verify-remote-history-is-clean.js +++ b/test/git-util/verify-remote-history-is-clean.js @@ -66,9 +66,7 @@ test('clean fetched remote history', createStubFixture, [ test('no remote', createIntegrationFixture, async () => { // }, async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { - const result = await t.notThrowsAsync( + await t.notThrowsAsync( verifyRemoteHistoryIsClean(), ); - - t.is(result, undefined); }); diff --git a/test/npm/util/collaborators.js b/test/npm/util/collaborators.js index 8afffd28..a97b2a52 100644 --- a/test/npm/util/collaborators.js +++ b/test/npm/util/collaborators.js @@ -13,76 +13,60 @@ test('package.name not a string', async t => { ); }); -const npmVersionFixtures = [ - {version: '9.0.0', accessCommand: 'npm access list collaborators np --json'}, -]; +const accessCommand = (name = 'np') => `npm access list collaborators ${name} --json`; -for (const {version, accessCommand} of npmVersionFixtures) { - const npmVersionCommand = { - command: 'npm --version', - stdout: version, - }; +const collaboratorsStdout = stripIndent` + { + "sindresorhus": "read-write", + "samverschueren": "read-write", + "itaisteinherz": "read-write" + } +`; - const collaboratorsStdout = stripIndent` - { - "sindresorhus": "read-write", - "samverschueren": "read-write", - "itaisteinherz": "read-write" - } - `; +test('main', createFixture, [{ + command: accessCommand(), + stdout: collaboratorsStdout, +}], async ({t, testedModule: {collaborators}}) => { + t.deepEqual( + await collaborators({name: 'np'}), + collaboratorsStdout, + ); +}); - test(`npm v${version}`, createFixture, [ - npmVersionCommand, - { - command: accessCommand, - stdout: collaboratorsStdout, +// TODO: this is timing out, seemingly the command isn't matching for Sinon +// eslint-disable-next-line ava/no-skip-test +test.skip('external registry', createFixture, [{ + command: `${accessCommand()} --registry http://my-internal-registry.local`, + stdout: collaboratorsStdout, +}], async ({t, testedModule: {collaborators}}) => { + const output = await collaborators({ + name: 'np', + publishConfig: { + registry: 'http://my-internal-registry.local', }, - ], async ({t, testedModule: {collaborators}}) => { - t.deepEqual( - await collaborators({name: 'np'}), - collaboratorsStdout, - ); }); - test(`npm v${version} - external registry`, createFixture, [ - npmVersionCommand, - { - command: `${accessCommand} --registry http://my-internal-registry.local`, - stdout: collaboratorsStdout, - }, - ], async ({t, testedModule: {collaborators}}) => { - t.deepEqual( - await collaborators({ - name: 'np', - publishConfig: { - registry: 'http://my-internal-registry.local', - }, - }), - collaboratorsStdout, - ); - }); + t.deepEqual( + JSON.parse(output), + JSON.parse(collaboratorsStdout), + ); +}); - test(`npm v${version} - non-existent`, createFixture, [ - npmVersionCommand, - { - command: 'npm access list collaborators non-existent --json', - stderr: 'npm ERR! code E404\nnpm ERR! 404 Not Found', - }, - ], async ({t, testedModule: {collaborators}}) => { - t.is( - await collaborators({name: 'non-existent'}), - false, - ); - }); +test('non-existent', createFixture, [{ + command: accessCommand('non-existent'), + stderr: 'npm ERR! code E404\nnpm ERR! 404 Not Found', +}], async ({t, testedModule: {collaborators}}) => { + t.is( + await collaborators({name: 'non-existent'}), + false, + ); +}); + +test('error', createFixture, [{ + command: accessCommand('@private/pkg'), + stderr: 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden', +}], async ({t, testedModule: {collaborators}}) => { + const {stderr} = await t.throwsAsync(collaborators({name: '@private/pkg'})); + t.is(stderr, 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden'); +}); - test(`npm v${version} - error`, createFixture, [ - npmVersionCommand, - { - command: 'npm access list collaborators @private/pkg --json', - stderr: 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden', - }, - ], async ({t, testedModule: {collaborators}}) => { - const {stderr} = await t.throwsAsync(collaborators({name: '@private/pkg'})); - t.is(stderr, 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden'); - }); -} From 5aa226f83f4e5863864d7d42662a4ba3fef10558 Mon Sep 17 00:00:00 2001 From: Tommy Date: Fri, 1 Mar 2024 00:35:22 -0600 Subject: [PATCH 084/180] Deduplicate `packageManager` creation (#735) --- source/cli-implementation.js | 16 ++++++++++++++-- source/index.js | 6 ++---- source/ui.js | 9 +-------- test/index.js | 2 ++ test/ui/new-files-dependencies.js | 3 ++- test/ui/prompts/tags.js | 2 ++ test/ui/prompts/version.js | 2 ++ 7 files changed, 25 insertions(+), 15 deletions(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 6daa24c9..73db73f6 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -5,6 +5,7 @@ import logSymbols from 'log-symbols'; import meow from 'meow'; import updateNotifier from 'update-notifier'; import {gracefulExit} from 'exit-hook'; +import {getPackageManagerConfig} from './package-manager/index.js'; import config from './config.js'; import * as util from './util.js'; import * as git from './git-util.js'; @@ -106,7 +107,7 @@ updateNotifier({pkg: cli.pkg}).notify(); /** @typedef {Awaited>['options']} Options */ -export async function getOptions() { +async function getOptions() { const {package_, rootDirectory} = await util.readPackage(cli.flags.contents); const localConfig = await config(rootDirectory); @@ -124,6 +125,12 @@ export async function getOptions() { package_.packageManager = flags.packageManager; } + const packageManager = getPackageManagerConfig(rootDirectory, package_); + + if (packageManager.throwOnExternalRegistry && npm.isExternalRegistry(package_)) { + throw new Error(`External registry is not yet supported with ${packageManager.id}.`); + } + const runPublish = !flags.releaseDraftOnly && flags.publish && !package_.private; const availability = runPublish ? await npm.isPackageNameAvailable(package_) : { @@ -138,13 +145,18 @@ export async function getOptions() { const options = await ui({ ...flags, + packageManager, runPublish, availability, version, branch, }, {package_, rootDirectory}); - return {options, rootDirectory, package_}; + return { + options: {...options, packageManager}, + rootDirectory, + package_, + }; } try { diff --git a/source/index.js b/source/index.js index e676bb9f..82e78398 100644 --- a/source/index.js +++ b/source/index.js @@ -18,7 +18,7 @@ import {getPackagePublishArguments} from './npm/publish.js'; import enable2fa, {getEnable2faArguments} from './npm/enable-2fa.js'; import handleNpmError from './npm/handle-npm-error.js'; import releaseTaskHelper from './release-task-helper.js'; -import {findLockfile, getPackageManagerConfig, printCommand} from './package-manager/index.js'; +import {findLockfile, printCommand} from './package-manager/index.js'; import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; @@ -36,9 +36,7 @@ const exec = (command, arguments_, options) => { @param {import('./cli-implementation.js').Options} options @param {{package_: import('read-pkg').NormalizedPackageJson; rootDirectory: string}} context */ -const np = async (input = 'patch', options, {package_, rootDirectory}) => { - const packageManager = getPackageManagerConfig(rootDirectory, package_); - +const np = async (input = 'patch', {packageManager, ...options}, {package_, rootDirectory}) => { // TODO: Remove sometime far in the future if (options.skipCleanup) { options.cleanup = false; diff --git a/source/ui.js b/source/ui.js index b10f92c3..b3394ffd 100644 --- a/source/ui.js +++ b/source/ui.js @@ -5,7 +5,6 @@ import {htmlEscape} from 'escape-goat'; import isScoped from 'is-scoped'; import isInteractive from 'is-interactive'; import {execa} from 'execa'; -import {getPackageManagerConfig} from './package-manager/index.js'; import Version, {SEMVER_INCREMENTS} from './version.js'; import * as util from './util.js'; import * as git from './git-util.js'; @@ -125,17 +124,11 @@ const checkNewFilesAndDependencies = async (package_, rootDirectory) => { @param {import('./cli-implementation.js').CLI['flags']} options @param {{package_: import('read-pkg').NormalizedPackageJson; rootDirectory: string}} context */ -const ui = async (options, {package_, rootDirectory}) => { +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 packageManager = getPackageManagerConfig(rootDirectory, package_); - - if (packageManager.throwOnExternalRegistry && npm.isExternalRegistry(package_)) { - throw new Error(`External registry is not yet supported with ${packageManager.id}.`); - } - const {stdout: registryUrl} = await execa(...packageManager.getRegistryCommand); const releaseBranch = options.branch; diff --git a/test/index.js b/test/index.js index b429a536..047104d8 100644 --- a/test/index.js +++ b/test/index.js @@ -1,6 +1,7 @@ import test from 'ava'; import sinon from 'sinon'; import esmock from 'esmock'; +import {npmConfig as packageManager} from '../source/package-manager/configs.js'; import * as util from '../source/util.js'; import np from '../source/index.js'; @@ -8,6 +9,7 @@ const defaultOptions = { cleanup: true, tests: true, publish: true, + packageManager, runPublish: true, availability: { isAvailable: false, diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index 0742dc43..9bd31f6b 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -4,6 +4,7 @@ import {execa} from 'execa'; import {removePackageDependencies, updatePackage} from 'write-package'; import stripAnsi from 'strip-ansi'; import {readPackage} from 'read-pkg'; +import {npmConfig as packageManager} from '../../source/package-manager/configs.js'; import {createIntegrationTest} from '../_helpers/integration-test.js'; import {mockInquirer} from '../_helpers/mock-inquirer.js'; @@ -52,7 +53,7 @@ const createFixture = test.macro(async (t, package_, commands, expected) => { }, }); - await ui({runPublish: true, version: 'major', yarn: false}, {package_, rootDirectory: temporaryDirectory}); + await ui({runPublish: true, version: 'major', packageManager}, {package_, rootDirectory: temporaryDirectory}); const logs = logsArray.join('').split('\n').map(log => stripAnsi(log)); const {unpublished, firstTime, dependencies} = expected; diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js index 77e1dedf..2edc8e07 100644 --- a/test/ui/prompts/tags.js +++ b/test/ui/prompts/tags.js @@ -1,5 +1,6 @@ import test from 'ava'; import sinon from 'sinon'; +import {npmConfig as packageManager} from '../../../source/package-manager/configs.js'; import {npPackage} from '../../../source/util.js'; import {mockInquirer} from '../../_helpers/mock-inquirer.js'; @@ -25,6 +26,7 @@ const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { }); const results = await ui({ + packageManager, runPublish: true, availability: {}, }, { diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index 03f33612..45c20c73 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -1,5 +1,6 @@ import test from 'ava'; import sinon from 'sinon'; +import {npmConfig as packageManager} from '../../../source/package-manager/configs.js'; import {mockInquirer} from '../../_helpers/mock-inquirer.js'; const testUi = test.macro(async (t, {version, answers}, assertions) => { @@ -20,6 +21,7 @@ const testUi = test.macro(async (t, {version, answers}, assertions) => { }); const results = await ui({ + packageManager, runPublish: false, availability: {}, }, { From 3b6b9c8c0d7749559bfe3b5a98ab56eb22d12df5 Mon Sep 17 00:00:00 2001 From: Tommy Date: Sun, 3 Mar 2024 10:06:18 -0600 Subject: [PATCH 085/180] Fix pnpm note (#736) --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 84699c54..339f412a 100644 --- a/readme.md +++ b/readme.md @@ -45,11 +45,11 @@ - Warns about the possibility of extraneous files being published - See exactly what will be executed with [preview mode](https://github.com/sindresorhus/np/issues/391), without pushing or publishing anything remotely - Supports [GitHub Packages](https://github.com/features/packages) +- Supports npm 9+, Yarn (Classic and Berry), and pnpm 8+ ### Why not - Monorepos are not supported. -- pnpm is not supported. - Custom registries are not supported ([but could be with your help](https://github.com/sindresorhus/np/issues/420)). - CI is [not an ideal environment](https://github.com/sindresorhus/np/issues/619#issuecomment-994493179) for `np`. It's meant to be used locally as an interactive tool. From 9fdebd5885218ec9a252f6204acf78f846315c8e Mon Sep 17 00:00:00 2001 From: Pelle Wessman Date: Wed, 13 Mar 2024 05:54:32 +0100 Subject: [PATCH 086/180] Fix compatibility with npm 10 (#737) --- source/npm/util.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index c1a6e2c5..5677847b 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -138,8 +138,12 @@ export const checkIgnoreStrategy = async ({files}, rootDirectory) => { }; export const getFilesToBePacked = async rootDirectory => { - const {stdout} = await execa('npm', ['pack', '--dry-run', '--json'], {cwd: rootDirectory}); + const {stdout} = await execa('npm', ['pack', '--dry-run', '--json', '--silent'], {cwd: rootDirectory}); - const {files} = JSON.parse(stdout).at(0); - return files.map(file => file.path); + try { + const {files} = JSON.parse(stdout).at(0); + return files.map(file => file.path); + } catch (error) { + throw new Error('Failed to parse output of npm pack', {cause: error}); + } }; From 50ab2d5e34d9e236cfa10adabc64f8feda56fd07 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 13 Mar 2024 11:55:51 +0700 Subject: [PATCH 087/180] Meta tweaks --- readme.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/readme.md b/readme.md index 339f412a..c68ee7b5 100644 --- a/readme.md +++ b/readme.md @@ -2,26 +2,6 @@ > A better `npm publish` ---- - -
-

-

- - My open source work is supported by the community - -

- Special thanks to: -
-
- - - -

-
- ---- - ## Why From da18f86875f187463ed7e8c4f80297e435790595 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 13 Mar 2024 13:09:36 +0700 Subject: [PATCH 088/180] 10.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 444fde7f..98b88a65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.0.0", + "version": "10.0.1", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 770418f00c32d1fb8a49c26340a0d7af53a216de Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 Mar 2024 02:44:36 -0400 Subject: [PATCH 089/180] Use npm for tagging versions when pnpm is the chosen package manager (#739) --- source/package-manager/configs.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/package-manager/configs.js b/source/package-manager/configs.js index 0f584bd7..dbbadf8d 100644 --- a/source/package-manager/configs.js +++ b/source/package-manager/configs.js @@ -17,7 +17,8 @@ export const pnpmConfig = { installCommand: ['pnpm', ['install']], installCommandNoLockfile: ['pnpm', ['install']], versionCommand: version => ['pnpm', ['version', version]], - tagVersionPrefixCommand: ['pnpm', ['config', 'get', 'tag-version-prefix']], + // Pnpm config doesn't have `v` as a default tag version prefix, so to get consistent default behavior, use npm. + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix']], getRegistryCommand: ['pnpm', ['config', 'get', 'registry']], lockfiles: ['pnpm-lock.yaml'], }; From af1a048c0cd22974eb0bc81a4118b2b832c9396a Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 24 Mar 2024 15:45:11 +0900 Subject: [PATCH 090/180] 10.0.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 98b88a65..3128d44c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.0.1", + "version": "10.0.2", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 02f60c70c5e702287f7477c6a96203df9e4cb4a4 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 7 Apr 2024 06:44:17 +0200 Subject: [PATCH 091/180] Fix publish OTP for Yarn Berry (#741) --- source/index.js | 6 +++--- source/npm/handle-npm-error.js | 6 +++++- source/npm/publish.js | 15 +++++++++++++++ test/index.js | 10 ++++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/source/index.js b/source/index.js index 82e78398..b6da323f 100644 --- a/source/index.js +++ b/source/index.js @@ -14,7 +14,7 @@ import {asyncExitHook} from 'exit-hook'; import logSymbols from 'log-symbols'; import prerequisiteTasks from './prerequisite-tasks.js'; import gitTasks from './git-tasks.js'; -import {getPackagePublishArguments} from './npm/publish.js'; +import {getPackagePublishArguments, runPublish} from './npm/publish.js'; import enable2fa, {getEnable2faArguments} from './npm/enable-2fa.js'; import handleNpmError from './npm/handle-npm-error.js'; import releaseTaskHelper from './release-task-helper.js'; @@ -179,12 +179,12 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root task(context, task) { let hasError = false; - return from(execa(...getPublishCommand(options))) + return from(runPublish(getPublishCommand(options))) .pipe( catchError(error => handleNpmError(error, task, otp => { context.otp = otp; - return execa(...getPublishCommand({...options, otp})); + return runPublish(getPublishCommand({...options, otp})); })), ) .pipe( diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index 9709e2cb..1ebaefab 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -9,7 +9,11 @@ const handleNpmError = (error, task, message, executor) => { } // `one-time pass` is for npm and `Two factor authentication` is for Yarn. - if (error.stderr.includes('one-time pass') || error.stdout.includes('Two factor authentication')) { + if ( + error.stderr.includes('one-time pass') // Npm + || error.stdout.includes('Two factor authentication') // Yarn v1 + || error.stdout.includes('One-time password:') // Yarn berry + ) { const {title} = task; task.title = `${title} ${chalk.yellow('(waiting for input…)')}`; diff --git a/source/npm/publish.js b/source/npm/publish.js index aeb2131e..f25ce2ee 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -1,3 +1,5 @@ +import {execa} from 'execa'; + export const getPackagePublishArguments = options => { const arguments_ = ['publish']; @@ -19,3 +21,16 @@ export const getPackagePublishArguments = options => { return arguments_; }; + +export function runPublish(arguments_) { + const cp = execa(...arguments_); + + cp.stdout.on('data', chunk => { + // https://github.com/yarnpkg/berry/blob/a3e5695186f2aec3a68810acafc6c9b1e45191da/packages/plugin-npm/sources/npmHttpUtils.ts#L541 + if (chunk.toString('utf8').includes('One-time password:')) { + cp.kill(); + } + }); + + return cp; +} diff --git a/test/index.js b/test/index.js index 047104d8..0224da46 100644 --- a/test/index.js +++ b/test/index.js @@ -62,7 +62,10 @@ test('skip enabling 2FA if the package exists', async t => { verifyWorkingTreeIsClean: sinon.stub(), }, '../source/npm/enable-2fa.js': enable2faStub, - '../source/npm/publish.js': sinon.stub().returns({pipe: sinon.stub()}), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().returns(fakeExecaReturn()), + }, }); await t.notThrowsAsync(npMock('1.0.0', { @@ -91,7 +94,10 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { verifyWorkingTreeIsClean: sinon.stub(), }, '../source/npm/enable-2fa.js': enable2faStub, - '../source/npm/publish.js': sinon.stub().returns({pipe: sinon.stub()}), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().returns(fakeExecaReturn()), + }, }); await t.notThrowsAsync(npMock('1.0.0', { From ad1a980f8fdb39c0d73403c973fd97b44c0bf23e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 7 Apr 2024 13:47:15 +0900 Subject: [PATCH 092/180] 10.0.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3128d44c..26d6d87e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.0.2", + "version": "10.0.3", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 4caa2955c0946b6be86897931a40b5101ee9edf4 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Wed, 17 Apr 2024 07:38:03 -0500 Subject: [PATCH 093/180] Fix compatibility with npm 10 (#743) --- source/npm/util.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/npm/util.js b/source/npm/util.js index 5677847b..724271d6 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -141,7 +141,9 @@ export const getFilesToBePacked = async rootDirectory => { const {stdout} = await execa('npm', ['pack', '--dry-run', '--json', '--silent'], {cwd: rootDirectory}); try { - const {files} = JSON.parse(stdout).at(0); + // TODO: Remove this once [npm/cli#7354](https://github.com/npm/cli/issues/7354) is resolved. + const cleanStdout = stdout.replace(/^[^[]*\[/, '[').trim(); + const {files} = JSON.parse(cleanStdout).at(0); return files.map(file => file.path); } catch (error) { throw new Error('Failed to parse output of npm pack', {cause: error}); From 5a83ad957f374cdafea9ef8abe52a6076f3f03bb Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 17 Apr 2024 21:40:56 +0900 Subject: [PATCH 094/180] 10.0.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 26d6d87e..de91dbf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.0.3", + "version": "10.0.4", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 34409bea7e51d1748a4be9ade59129446e6fea83 Mon Sep 17 00:00:00 2001 From: Karl Horky Date: Fri, 19 Apr 2024 05:32:04 +0200 Subject: [PATCH 095/180] Fix npm 10 compatibility for real (#744) --- source/npm/util.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index 724271d6..02736fcb 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -138,12 +138,17 @@ export const checkIgnoreStrategy = async ({files}, rootDirectory) => { }; export const getFilesToBePacked = async rootDirectory => { - const {stdout} = await execa('npm', ['pack', '--dry-run', '--json', '--silent'], {cwd: rootDirectory}); + const {stdout} = await execa('npm', [ + 'pack', + '--dry-run', + '--json', + '--silent', + // TODO: Remove this once [npm/cli#7354](https://github.com/npm/cli/issues/7354) is resolved. + '--foreground-scripts=false', + ], {cwd: rootDirectory}); try { - // TODO: Remove this once [npm/cli#7354](https://github.com/npm/cli/issues/7354) is resolved. - const cleanStdout = stdout.replace(/^[^[]*\[/, '[').trim(); - const {files} = JSON.parse(cleanStdout).at(0); + const {files} = JSON.parse(stdout).at(0); return files.map(file => file.path); } catch (error) { throw new Error('Failed to parse output of npm pack', {cause: error}); From d2c66a8316c4c3c8b0eaafd83073feb53cc2b491 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 19 Apr 2024 12:33:29 +0900 Subject: [PATCH 096/180] 10.0.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de91dbf5..e66335b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.0.4", + "version": "10.0.5", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From d0c1493396381ee38accdbdc2107aec0c3d742ec Mon Sep 17 00:00:00 2001 From: Karl Horky Date: Wed, 19 Jun 2024 02:27:38 +0200 Subject: [PATCH 097/180] Fix usage with `pnpm` and `--any-branch` or `--branch` (#745) --- source/package-manager/configs.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/package-manager/configs.js b/source/package-manager/configs.js index dbbadf8d..05f9e23d 100644 --- a/source/package-manager/configs.js +++ b/source/package-manager/configs.js @@ -17,8 +17,10 @@ export const pnpmConfig = { installCommand: ['pnpm', ['install']], installCommandNoLockfile: ['pnpm', ['install']], versionCommand: version => ['pnpm', ['version', version]], - // Pnpm config doesn't have `v` as a default tag version prefix, so to get consistent default behavior, use npm. + // By default, pnpm config returns `undefined` instead of `v` for tag-version-prefix, so for consistent default behavior, use npm. tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix']], + // Disable duplicated pnpm Git checks + publishCommand: arguments_ => ['pnpm', [...arguments_, '--no-git-checks']], getRegistryCommand: ['pnpm', ['config', 'get', 'registry']], lockfiles: ['pnpm-lock.yaml'], }; From a9e8fc78f06f5d429f5040d4ad24c696b27e30f4 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 19 Jun 2024 02:37:32 +0200 Subject: [PATCH 098/180] 10.0.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e66335b6..79b75e3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.0.5", + "version": "10.0.6", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 6c5eee329b202662dfb9b1696670d44f52ca3dfb Mon Sep 17 00:00:00 2001 From: fisker Cheung Date: Wed, 17 Jul 2024 06:46:23 +0800 Subject: [PATCH 099/180] Allow `publishConfig.registry` to be npm default registry when using Yarn berry (#750) --- source/npm/util.js | 11 ++++++++++- test/npm/util/is-external-registry.js | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/source/npm/util.js b/source/npm/util.js index 02736fcb..4ea325e6 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -43,7 +43,16 @@ export const username = async ({externalRegistry}) => { } }; -export const isExternalRegistry = package_ => typeof package_.publishConfig?.registry === 'string'; +const NPM_DEFAULT_REGISTRIES = new Set([ + // https://docs.npmjs.com/cli/v10/using-npm/registry + 'https://registry.npmjs.org', + // https://docs.npmjs.com/cli/v10/commands/npm-profile#registry + 'https://registry.npmjs.org/', +]); +export const isExternalRegistry = package_ => { + const registry = package_.publishConfig?.registry; + return typeof registry === 'string' && !NPM_DEFAULT_REGISTRIES.has(registry); +}; export const collaborators = async package_ => { const packageName = package_.name; diff --git a/test/npm/util/is-external-registry.js b/test/npm/util/is-external-registry.js index c449cc66..6b7bcadb 100644 --- a/test/npm/util/is-external-registry.js +++ b/test/npm/util/is-external-registry.js @@ -7,4 +7,6 @@ test('main', t => { t.false(npm.isExternalRegistry({name: 'foo'})); t.false(npm.isExternalRegistry({publishConfig: {registry: true}})); t.false(npm.isExternalRegistry({publishConfig: 'not an object'})); + t.false(npm.isExternalRegistry({publishConfig: {registry: 'https://registry.npmjs.org'}})); + t.false(npm.isExternalRegistry({publishConfig: {registry: 'https://registry.npmjs.org/'}})); }); From 1965a6b3dfe8ea78806b70c5ebe23a95a77b4156 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 17 Jul 2024 00:48:50 +0200 Subject: [PATCH 100/180] 10.0.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 79b75e3c..d57d95a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.0.6", + "version": "10.0.7", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From e3a122a34af4d8bb9e362244959e5589e4f665e6 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 3 Sep 2024 20:50:28 +0700 Subject: [PATCH 101/180] Add divider before the commit range in the generated release notes --- source/ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/ui.js b/source/ui.js index b3394ffd..5d33c671 100644 --- a/source/ui.js +++ b/source/ui.js @@ -66,7 +66,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch const releaseNotes = nextTag => commits.map(commit => `- ${htmlEscape(commit.message)} ${commit.id}`, - ).join('\n') + `\n\n${repoUrl}/compare/${revision}...${nextTag}`; + ).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`); From 8fdac0cf013955f4aac4ea9b4ab4b9902a2cb65a Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 23 Nov 2024 21:52:25 +0700 Subject: [PATCH 102/180] Update dependencies --- package.json | 28 ++++++++++++++-------------- source/version.js | 2 -- test/_helpers/mock-inquirer.js | 4 ++-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index d57d95a4..9757f62d 100644 --- a/package.json +++ b/package.json @@ -35,30 +35,30 @@ "chalk": "^5.3.0", "chalk-template": "^1.1.0", "cosmiconfig": "^8.3.6", - "del": "^7.1.0", + "del": "^8.0.0", "escape-goat": "^4.0.0", "escape-string-regexp": "^5.0.0", "execa": "^8.0.1", "exit-hook": "^4.0.0", "github-url-from-git": "^1.5.0", - "hosted-git-info": "^7.0.1", - "ignore-walk": "^6.0.4", - "import-local": "^3.1.0", + "hosted-git-info": "^8.0.2", + "ignore-walk": "^7.0.0", + "import-local": "^3.2.0", "inquirer": "^9.2.15", "is-installed-globally": "^1.0.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", - "issue-regex": "^4.1.0", + "issue-regex": "^4.3.0", "listr": "^0.14.3", "listr-input": "^0.2.1", - "log-symbols": "^6.0.0", + "log-symbols": "^7.0.0", "meow": "^13.2.0", "new-github-release-url": "^2.0.0", "npm-name": "^8.0.0", "onetime": "^7.0.0", "open": "^10.0.4", "p-memoize": "^7.1.1", - "p-timeout": "^6.1.2", + "p-timeout": "^6.1.3", "path-exists": "^5.0.0", "pkg-dir": "^8.0.0", "read-package-up": "^11.0.0", @@ -67,21 +67,21 @@ "semver": "^7.6.0", "symbol-observable": "^4.0.0", "terminal-link": "^3.0.0", - "update-notifier": "^7.0.0" + "update-notifier": "^7.3.1" }, "devDependencies": { - "@sindresorhus/is": "^6.2.0", + "@sindresorhus/is": "^7.0.1", "@types/semver": "^7.5.8", - "ava": "^6.1.2", + "ava": "^6.2.0", "common-tags": "^1.8.2", - "esmock": "^2.6.4", + "esmock": "^2.6.9", "fs-extra": "^11.2.0", "map-obj": "^5.0.2", - "sinon": "^17.0.1", + "sinon": "^19.0.2", "strip-ansi": "^7.1.0", "tempy": "^3.1.0", - "write-package": "^7.0.1", - "xo": "^0.57.0" + "write-package": "^7.1.0", + "xo": "^0.59.3" }, "ava": { "files": [ diff --git a/source/version.js b/source/version.js index 064edfef..5b1fc999 100644 --- a/source/version.js +++ b/source/version.js @@ -30,10 +30,8 @@ const formatFirstDifference = (current, previous, diffColor) => { export default class Version { /** @type {SemVerInstance} */ #version; - /** @type {SemVerIncrement | undefined} */ #diff = undefined; - /** @type {string | undefined} */ #prereleasePrefix = undefined; diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js index 1af5d6ce..2306237d 100644 --- a/test/_helpers/mock-inquirer.js +++ b/test/_helpers/mock-inquirer.js @@ -48,7 +48,7 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { continue; } - if (is.function_(prompt.when) && !prompt.when(answers)) { + if (is.function(prompt.when) && !prompt.when(answers)) { t.log(`skipping prompt '${name}'`); continue; } @@ -91,7 +91,7 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { if (is.asyncFunction(prompt.choices)) { choices = await prompt.choices(answers); - } else if (is.function_(prompt.choices)) { + } else if (is.function(prompt.choices)) { choices = prompt.choices(answers); } else { choices = prompt.choices; From cbc6f3b36353443cab125338c8d748fada53fb4a Mon Sep 17 00:00:00 2001 From: LitoMore Date: Sat, 23 Nov 2024 23:35:34 +0800 Subject: [PATCH 103/180] Add support for Bun (#756) Co-authored-by: Sindre Sorhus --- package.json | 3 ++- readme.md | 2 +- source/package-manager/configs.js | 15 +++++++++++++++ source/package-manager/index.js | 4 ++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9757f62d..b276cab7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "npm": ">=9", "git": ">=2.11.0", "yarn": ">=1.7.0", - "pnpm": ">=8" + "pnpm": ">=8", + "bun": ">=1" }, "scripts": { "test": "xo && ava" diff --git a/readme.md b/readme.md index c68ee7b5..22ba1d0e 100644 --- a/readme.md +++ b/readme.md @@ -25,7 +25,7 @@ - Warns about the possibility of extraneous files being published - See exactly what will be executed with [preview mode](https://github.com/sindresorhus/np/issues/391), without pushing or publishing anything remotely - Supports [GitHub Packages](https://github.com/features/packages) -- Supports npm 9+, Yarn (Classic and Berry), and pnpm 8+ +- Supports npm 9+, Yarn (Classic and Berry), npm 8+, and Bun ### Why not diff --git a/source/package-manager/configs.js b/source/package-manager/configs.js index 05f9e23d..93afb59a 100644 --- a/source/package-manager/configs.js +++ b/source/package-manager/configs.js @@ -52,3 +52,18 @@ export const yarnBerryConfig = { throwOnExternalRegistry: true, lockfiles: ['yarn.lock'], }; + +/** @type {import('./types.d.ts').PackageManagerConfig} */ +export const bunConfig = { + cli: 'bun', + id: 'bun', + installCommand: ['bun', ['install', '--frozen-lockfile']], + installCommandNoLockfile: ['bun', ['install', '--no-save']], + versionCommand: version => ['npm', ['version', version]], + // Bun doesn't support publishing, so we use npm instead. See https://github.com/oven-sh/bun/issues/5050 + publishCommand: arguments_ => ['npm', arguments_], + // TODO: Bun doesn't support config get registry, this should be added in the future. See https://github.com/oven-sh/bun/issues/7140 + getRegistryCommand: ['npm', ['config', 'get', 'registry']], + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix']], + lockfiles: ['bun.lockb'], +}; diff --git a/source/package-manager/index.js b/source/package-manager/index.js index 19279191..e779cb3c 100644 --- a/source/package-manager/index.js +++ b/source/package-manager/index.js @@ -46,6 +46,10 @@ function configFromPackageManagerField(package_) { return configs.yarnConfig; } + if (packageManager === 'bun') { + return configs.bunConfig; + } + throw new Error(`Invalid package manager: ${package_.packageManager}`); } From 37fc2e9dcb7d83af8981521ece4daf8b3ff9bef0 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 23 Nov 2024 22:37:30 +0700 Subject: [PATCH 104/180] 10.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b276cab7..66ed6c2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.0.7", + "version": "10.1.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 15be5cfdaef102e4456d5a9777bae6ddc44670e4 Mon Sep 17 00:00:00 2001 From: LitoMore Date: Sun, 24 Nov 2024 00:13:45 +0800 Subject: [PATCH 105/180] Fix typo (#760) --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 22ba1d0e..6dbdd831 100644 --- a/readme.md +++ b/readme.md @@ -25,7 +25,7 @@ - Warns about the possibility of extraneous files being published - See exactly what will be executed with [preview mode](https://github.com/sindresorhus/np/issues/391), without pushing or publishing anything remotely - Supports [GitHub Packages](https://github.com/features/packages) -- Supports npm 9+, Yarn (Classic and Berry), npm 8+, and Bun +- Supports npm 9+, Yarn (Classic and Berry), pnpm 8+, and Bun ### Why not From 8187dfe88102fe98e10764ae247135d9c47060ce Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Sun, 26 Jan 2025 20:45:50 +0900 Subject: [PATCH 106/180] Support `bun.lock` lock file (#761) --- source/package-manager/configs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/package-manager/configs.js b/source/package-manager/configs.js index 93afb59a..2e59f444 100644 --- a/source/package-manager/configs.js +++ b/source/package-manager/configs.js @@ -65,5 +65,5 @@ export const bunConfig = { // TODO: Bun doesn't support config get registry, this should be added in the future. See https://github.com/oven-sh/bun/issues/7140 getRegistryCommand: ['npm', ['config', 'get', 'registry']], tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix']], - lockfiles: ['bun.lockb'], + lockfiles: ['bun.lockb', 'bun.lock'], }; From a45f816f1883575dd91c719209a300c58554a945 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 26 Jan 2025 18:57:35 +0700 Subject: [PATCH 107/180] Update dependencies --- package.json | 12 ++++++------ source/ui.js | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 66ed6c2f..15fa6697 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "commit" ], "dependencies": { - "chalk": "^5.3.0", + "chalk": "^5.4.1", "chalk-template": "^1.1.0", "cosmiconfig": "^8.3.6", "del": "^8.0.0", @@ -45,7 +45,7 @@ "hosted-git-info": "^8.0.2", "ignore-walk": "^7.0.0", "import-local": "^3.2.0", - "inquirer": "^9.2.15", + "inquirer": "^12.3.2", "is-installed-globally": "^1.0.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", @@ -59,7 +59,7 @@ "onetime": "^7.0.0", "open": "^10.0.4", "p-memoize": "^7.1.1", - "p-timeout": "^6.1.3", + "p-timeout": "^6.1.4", "path-exists": "^5.0.0", "pkg-dir": "^8.0.0", "read-package-up": "^11.0.0", @@ -75,14 +75,14 @@ "@types/semver": "^7.5.8", "ava": "^6.2.0", "common-tags": "^1.8.2", - "esmock": "^2.6.9", - "fs-extra": "^11.2.0", + "esmock": "^2.7.0", + "fs-extra": "^11.3.0", "map-obj": "^5.0.2", "sinon": "^19.0.2", "strip-ansi": "^7.1.0", "tempy": "^3.1.0", "write-package": "^7.1.0", - "xo": "^0.59.3" + "xo": "^0.60.0" }, "ava": { "files": [ diff --git a/source/ui.js b/source/ui.js index 5d33c671..90a2cc7a 100644 --- a/source/ui.js +++ b/source/ui.js @@ -252,6 +252,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { 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()}`, From e2a2e36f7fac5ca9165bab9f84e578ef17f0c276 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 26 Jan 2025 19:00:02 +0700 Subject: [PATCH 108/180] 10.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 15fa6697..f3dc8a10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.1.0", + "version": "10.2.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 04dc20651316ba4697ac998bfe864cf53c776b83 Mon Sep 17 00:00:00 2001 From: Emil Sadek Date: Fri, 31 Jan 2025 03:12:10 -0800 Subject: [PATCH 109/180] Change code style badge (#762) --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 6dbdd831..ff706aff 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# np [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) +# np [![XO code style](https://shields.io/badge/code_style-5ed9c7?logo=xo&labelColor=gray&logoSize=auto&logoWidth=20)](https://github.com/xojs/xo) > A better `npm publish` From 571cba29409603f39ed9c622faaa2c82e6d49af2 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky Date: Mon, 31 Mar 2025 11:25:55 -0400 Subject: [PATCH 110/180] update test names --- test/util/hyperlinks.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js index 8ddfb6fe..01fe1b74 100644 --- a/test/util/hyperlinks.js +++ b/test/util/hyperlinks.js @@ -32,7 +32,7 @@ test('linkifyIssues returns raw message if url is not provided', verifyLinks, { t.is(linkifyIssues(undefined, message), message); }); -test('linkifyIssues returns raw message if terminalLink is not supported', verifyLinks, { +test('linkifyIssues returns raw message and url if terminalLink is not supported', verifyLinks, { linksSupported: false, }, ({t, util: {linkifyIssues}}) => { const message = 'Commit message - fixes #6'; @@ -51,7 +51,7 @@ test('linkifyCommit returns raw commit hash if url is not provided', verifyLinks t.is(linkifyCommit(undefined, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); }); -test('linkifyCommit returns raw commit hash if terminalLink is not supported', verifyLinks, { +test('linkifyCommit returns raw commit hash and url if terminalLink is not supported', verifyLinks, { linksSupported: false, }, ({t, util: {linkifyCommit}}) => { t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); @@ -63,7 +63,7 @@ test('linkifyCommitRange returns raw commitRange if url is not provided', verify t.is(linkifyCommitRange(undefined, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); }); -test('linkifyCommitRange returns raw commitRange if terminalLink is not supported', verifyLinks, { +test('linkifyCommitRange returns raw commitRange and url if terminalLink is not supported', verifyLinks, { linksSupported: false, }, ({t, util: {linkifyCommitRange}}) => { t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); From 92aa54a06d1082cf71603f4f07e9dc83317e9891 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 5 Jun 2025 20:59:05 +0200 Subject: [PATCH 111/180] Meta tweaks --- package.json | 2 +- test/config.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index f3dc8a10..77d3d07f 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "p-memoize": "^7.1.1", "p-timeout": "^6.1.4", "path-exists": "^5.0.0", - "pkg-dir": "^8.0.0", + "package-directory": "^8.0.0", "read-package-up": "^11.0.0", "read-pkg": "^9.0.1", "rxjs": "^7.8.1", diff --git a/test/config.js b/test/config.js index 4bb07ebe..e365bd81 100644 --- a/test/config.js +++ b/test/config.js @@ -8,7 +8,7 @@ const getFixture = fixture => path.resolve('test', 'fixtures', 'config', fixture const getFixtures = fixtures => fixtures.map(fixture => getFixture(fixture)); const getConfigsWhenGlobalBinaryIsUsed = async homeDirectory => { - const pathsPackageDirectory = getFixtures(['pkg-dir', 'local1', 'local2', 'local3']); + const pathsPackageDirectory = getFixtures(['package-directory', 'local1', 'local2', 'local3']); const promises = pathsPackageDirectory.map(async pathPackageDirectory => { const getConfig = await esmock(testedModulePath, { @@ -72,7 +72,7 @@ test('returns config from home directory when global binary is used and `.np-con ); test('returns config from package directory when local binary is used and `package.json` exists in package directory', - useLocalBinary, 'pkg-dir', 'package.json', + useLocalBinary, 'package-directory', 'package.json', ); test('returns config from package directory when local binary is used and `.np-config.json` exists in package directory', From 75a2e36dc8467f6e1cc78d02deb0c6073b06dc80 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 10 Aug 2025 01:58:56 +0200 Subject: [PATCH 112/180] Fix broken link Fixes #769 --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index ff706aff..6a68e88c 100644 --- a/readme.md +++ b/readme.md @@ -106,7 +106,7 @@ Currently, these are the flags you can configure: - `testScript` - Name of npm run script to run tests before publishing (`test` by default). - `2fa` - Enable 2FA on new packages (`true` by default) (setting this to `false` is not recommended). - `message` - The commit message used for the version bump. Any `%s` in the string will be replaced with the new version. By default, npm uses `%s` and Yarn uses `v%s`. -- `packageManager` - Set the package manager to be used. Defaults to the [packageManager field in package.json](https://nodejs.org/api/packages.html#packagemanager), so only use if you can't update package.json for some reason. +- `packageManager` - Set the package manager to be used. Defaults to the [packageManager field in package.json](https://nodejs.org/dist/latest-v16.x/docs/api/all.html#all_packages_packagemanager), so only use if you can't update package.json for some reason. For example, this configures `np` to use `unit-test` as a test script, and to use `dist` as the subdirectory to publish: From 38abeee9a4f48332751967d11b5a56009eca9dd0 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 15 Jan 2026 14:43:15 +0100 Subject: [PATCH 113/180] Auto-run `npm login` on authentication failure Fixes #774 --- source/cli-implementation.js | 19 +++++++++++++++++++ source/npm/util.js | 17 +++++++++++++++-- source/prerequisite-tasks.js | 5 ++--- test/_helpers/stub-execa.js | 9 +++++++-- test/npm/util/login.js | 27 +++++++++++++++++++++++++++ test/npm/util/username.js | 19 ++++++++++++++++--- 6 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 test/npm/util/login.js diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 73db73f6..dfe376d4 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -4,6 +4,7 @@ import 'symbol-observable'; // Important: This needs to be first to prevent weir import logSymbols from 'log-symbols'; import meow from 'meow'; import updateNotifier from 'update-notifier'; +import isInteractive from 'is-interactive'; import {gracefulExit} from 'exit-hook'; import {getPackageManagerConfig} from './package-manager/index.js'; import config from './config.js'; @@ -166,6 +167,24 @@ try { gracefulExit(); } + // Check authentication early, before Listr starts (so login can be interactive) + if (options.runPublish) { + const externalRegistry = npm.isExternalRegistry(package_) + ? package_.publishConfig.registry + : false; + + try { + await npm.username({externalRegistry}); + } catch (error) { + if (error.isNotLoggedIn && isInteractive()) { + console.log('\nYou must be logged in to publish. Running `npm login`...\n'); + await npm.login({externalRegistry}); + } else { + throw error; + } + } + } + console.log(); // Prints a newline for readability const newPackage = await np(options.version, options, {package_, rootDirectory}); diff --git a/source/npm/util.js b/source/npm/util.js index 4ea325e6..548b6d10 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -36,13 +36,26 @@ export const username = async ({externalRegistry}) => { const {stdout} = await execa('npm', arguments_); return stdout; } catch (error) { - const message = /ENEEDAUTH/.test(error.stderr) + const isNotLoggedIn = /ENEEDAUTH|E401/.test(error.stderr); + const message = isNotLoggedIn ? 'You must be logged in. Use `npm login` and try again.' : 'Authentication error. Use `npm whoami` to troubleshoot.'; - throw new Error(message); + const authError = new Error(message); + authError.isNotLoggedIn = isNotLoggedIn; + throw authError; } }; +export const login = async ({externalRegistry}) => { + const arguments_ = ['login']; + + if (externalRegistry) { + arguments_.push('--registry', externalRegistry); + } + + await execa('npm', arguments_, {stdio: 'inherit'}); +}; + const NPM_DEFAULT_REGISTRIES = new Set([ // https://docs.npmjs.com/cli/v10/using-npm/registry 'https://registry.npmjs.org', diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 5655f31e..f3f11404 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -27,9 +27,8 @@ const prerequisiteTasks = (input, package_, options, packageManager) => { title: 'Verify user is authenticated', enabled: () => process.env.NODE_ENV !== 'test' && !package_.private, async task() { - const username = await npm.username({ - externalRegistry: isExternalRegistry ? package_.publishConfig.registry : false, - }); + const externalRegistry = isExternalRegistry ? package_.publishConfig.registry : false; + const username = await npm.username({externalRegistry}); const collaborators = await npm.collaborators(package_); if (!collaborators) { diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js index 7132023d..f80237ef 100644 --- a/test/_helpers/stub-execa.js +++ b/test/_helpers/stub-execa.js @@ -37,8 +37,13 @@ const stubExeca = commands => { return { execa: { async execa(...arguments_) { - execaStub.resolves(execa(...arguments_)); - return execaStub(...arguments_); + // Only call real execa if stub doesn't have a match + const result = execaStub(...arguments_); + if (result === undefined) { + return execa(...arguments_); + } + + return result; }, }, }; diff --git a/test/npm/util/login.js b/test/npm/util/login.js new file mode 100644 index 00000000..e5b7d1a5 --- /dev/null +++ b/test/npm/util/login.js @@ -0,0 +1,27 @@ +import test from 'ava'; +import {_createFixture} from '../../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../../source/npm/util.js', import.meta.url); + +test('main', createFixture, [{ + command: 'npm login', + stdout: '', +}], async ({t, testedModule: npm}) => { + await t.notThrowsAsync(npm.login({})); +}); + +test('--registry flag', createFixture, [{ + command: 'npm login --registry http://my.io', + stdout: '', +}], async ({t, testedModule: npm}) => { + await t.notThrowsAsync(npm.login({externalRegistry: 'http://my.io'})); +}); + +test('fails if login fails', createFixture, [{ + command: 'npm login', + exitCode: 1, + stderr: 'npm ERR! Login failed', +}], async ({t, testedModule: npm}) => { + await t.throwsAsync(npm.login({})); +}); diff --git a/test/npm/util/username.js b/test/npm/util/username.js index 87235890..f1bfc0c7 100644 --- a/test/npm/util/username.js +++ b/test/npm/util/username.js @@ -18,22 +18,35 @@ test('--registry flag', createFixture, [{ t.is(await npm.username({externalRegistry: 'http://my.io'}), 'sindresorhus'); }); -test('fails if not logged in', createFixture, [{ +test('fails if not logged in - ENEEDAUTH', createFixture, [{ command: 'npm whoami', stderr: 'npm ERR! code ENEEDAUTH', }], async ({t, testedModule: npm}) => { - await t.throwsAsync( + const error = await t.throwsAsync( npm.username({}), {message: 'You must be logged in. Use `npm login` and try again.'}, ); + t.true(error.isNotLoggedIn); +}); + +test('fails if not logged in - E401', createFixture, [{ + command: 'npm whoami', + stderr: 'npm error code E401\nnpm error 401 Unauthorized', +}], async ({t, testedModule: npm}) => { + const error = await t.throwsAsync( + npm.username({}), + {message: 'You must be logged in. Use `npm login` and try again.'}, + ); + t.true(error.isNotLoggedIn); }); test('fails with authentication error', createFixture, [{ command: 'npm whoami', stderr: 'npm ERR! OTP required for authentication', }], async ({t, testedModule: npm}) => { - await t.throwsAsync( + const error = await t.throwsAsync( npm.username({}), {message: 'Authentication error. Use `npm whoami` to troubleshoot.'}, ); + t.false(error.isNotLoggedIn); }); From 005ebdbb0d7d8d9ba1fcc5a2120ae1373cfe8e5b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 18 Jan 2026 14:39:18 +0100 Subject: [PATCH 114/180] Fix rollback not executing when publish fails --- source/index.js | 11 ++++++++--- test/index.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/source/index.js b/source/index.js index b6da323f..6d098bca 100644 --- a/source/index.js +++ b/source/index.js @@ -7,6 +7,8 @@ import { filter, finalize, from, + mergeMap, + throwError, } from 'rxjs'; import hostedGitInfo from 'hosted-git-info'; import onetime from 'onetime'; @@ -188,10 +190,13 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root })), ) .pipe( - catchError(async error => { + // Note: Cannot use `async` here as the `await` will not finish before the error propagates. + catchError(error => { hasError = true; - await rollback(); - throw new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`); + return from(rollback()).pipe( + mergeMap(() => throwError(() => new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`))), + catchError(() => throwError(() => new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`))), + ); }), finalize(() => { publishStatus = hasError ? 'FAILED' : 'SUCCESS'; diff --git a/test/index.js b/test/index.js index 0224da46..5c64cd52 100644 --- a/test/index.js +++ b/test/index.js @@ -1,3 +1,4 @@ +import process from 'node:process'; import test from 'ava'; import sinon from 'sinon'; import esmock from 'esmock'; @@ -111,3 +112,49 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { t.true(enable2faStub.notCalled); }); + +const fakeExecaReject = error => Object.assign( + Promise.reject(Object.assign(new Error(error), {stdout: '', stderr: error})), + {stdout: '', stderr: ''}, +); + +test('rollback is called when publish fails', async t => { + const deleteTagStub = sinon.stub().resolves(); + const removeLastCommitStub = sinon.stub().resolves(); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + latestTag: sinon.stub().resolves('v1.0.0'), + deleteTag: deleteTagStub, + removeLastCommit: removeLastCommitStub, + }, + '../source/npm/enable-2fa.js': sinon.stub(), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().returns(fakeExecaReject('npm ERR! publish failed')), + }, + '../source/util.js': { + ...util, + readPackage: sinon.stub().resolves({version: '1.0.0'}), + getTagVersionPrefix: sinon.stub().resolves('v'), + }, + }); + + await t.throwsAsync( + npMock('1.0.0', { + ...defaultOptions, + }, {package_: {version: '0.9.0'}, rootDirectory: process.cwd()}), + {message: /Error publishing package/}, + ); + + t.true(deleteTagStub.calledOnce, 'deleteTag should be called once'); + t.true(removeLastCommitStub.calledOnce, 'removeLastCommit should be called once'); +}); From 0ad9a8cf575685e107c35913f0c510e7df59bda1 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 18 Jan 2026 14:40:50 +0100 Subject: [PATCH 115/180] Include stderr in error message for failed commands --- source/index.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/source/index.js b/source/index.js index 6d098bca..f2cc410f 100644 --- a/source/index.js +++ b/source/index.js @@ -30,7 +30,17 @@ const exec = (command, arguments_, options) => { // Use `Observable` support if merged https://github.com/sindresorhus/execa/pull/26 const subProcess = execa(command, arguments_, options); - return merge(subProcess.stdout, subProcess.stderr, subProcess).pipe(filter(Boolean)); + return merge(subProcess.stdout, subProcess.stderr, subProcess).pipe( + filter(Boolean), + catchError(error => { + // Include stderr in error message for better diagnostics + if (error.stderr) { + error.message = `${error.shortMessage}\n${error.stderr}`; + } + + throw error; + }), + ); }; /** From 6ab0a382ac77accbea0643f91f22564e15662ed9 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 18 Jan 2026 14:56:03 +0100 Subject: [PATCH 116/180] Fix CI --- .github/workflows/main.yml | 7 +++---- test/config.js | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 87a31599..faeaa6c8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,12 +10,11 @@ jobs: fail-fast: false matrix: node-version: - - 21 + - 24 - 20 - - 18 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - run: git config --global user.name "Github Actions" diff --git a/test/config.js b/test/config.js index e365bd81..c337ac28 100644 --- a/test/config.js +++ b/test/config.js @@ -63,7 +63,7 @@ test('returns config from home directory when global binary is used and `.np-con useGlobalBinary, 'homedir3', 'homedir/.np-config.cjs', ); -test.failing('returns config from home directory when global binary is used and `.np-config.js` as ESM exists in home directory', +test('returns config from home directory when global binary is used and `.np-config.js` as ESM exists in home directory', useGlobalBinary, 'homedir4', 'homedir/.np-config.js', ); @@ -72,7 +72,7 @@ test('returns config from home directory when global binary is used and `.np-con ); test('returns config from package directory when local binary is used and `package.json` exists in package directory', - useLocalBinary, 'package-directory', 'package.json', + useLocalBinary, 'pkg-dir', 'package.json', ); test('returns config from package directory when local binary is used and `.np-config.json` exists in package directory', From de75827940bf79b2f49fbf4722b8de3011b72982 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 18 Jan 2026 15:04:33 +0100 Subject: [PATCH 117/180] 10.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77d3d07f..f9f61527 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.2.0", + "version": "10.3.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 307ebec1e88c11ce3c6c72764204d13534a90491 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sun, 18 Jan 2026 15:09:18 +0100 Subject: [PATCH 118/180] Require Node.js 20 Closes #765 --- package.json | 68 ++++++----- source/cli-implementation.js | 19 +-- source/index.js | 112 ++++++++++-------- source/npm/enable-2fa.js | 4 +- source/npm/handle-npm-error.js | 4 +- source/npm/util.js | 41 ++++--- source/ui.js | 7 +- source/util.js | 6 +- source/version.js | 24 ++-- test/_helpers/mock-inquirer.js | 4 +- test/_helpers/stub-execa.js | 13 +- test/config.js | 77 ++++++++---- test/git-util/push-graceful.js | 8 +- ...verify-current-branch-is-release-branch.js | 4 +- test/git-util/verify-recent-git-version.js | 4 +- .../verify-remote-history-is-clean.js | 8 +- test/git-util/verify-remote-is-valid.js | 4 +- .../verify-tag-does-not-exist-on-remote.js | 4 +- test/git-util/verify-working-tree-is-clean.js | 4 +- test/index.js | 38 +++--- test/npm/enable-2fa.js | 42 +++---- test/npm/util/verify-recent-npm-version.js | 4 +- test/tasks/git-tasks.js | 12 +- test/tasks/prerequisite-tasks.js | 44 +++---- test/util/hyperlinks.js | 12 +- .../util/validate-engine-version-satisfies.js | 5 +- 26 files changed, 309 insertions(+), 263 deletions(-) diff --git a/package.json b/package.json index f9f61527..42792e29 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "module", "bin": "./source/cli.js", "engines": { - "node": ">=18", + "node": ">=20", "npm": ">=9", "git": ">=2.11.0", "yarn": ">=1.7.0", @@ -33,56 +33,56 @@ "commit" ], "dependencies": { - "chalk": "^5.4.1", - "chalk-template": "^1.1.0", - "cosmiconfig": "^8.3.6", - "del": "^8.0.0", + "chalk": "^5.6.2", + "chalk-template": "^1.1.2", + "cosmiconfig": "^9.0.0", + "del": "^8.0.1", "escape-goat": "^4.0.0", "escape-string-regexp": "^5.0.0", - "execa": "^8.0.1", - "exit-hook": "^4.0.0", + "execa": "^9.6.1", + "exit-hook": "^5.0.1", "github-url-from-git": "^1.5.0", - "hosted-git-info": "^8.0.2", - "ignore-walk": "^7.0.0", + "hosted-git-info": "^9.0.2", + "ignore-walk": "^8.0.0", "import-local": "^3.2.0", - "inquirer": "^12.3.2", + "inquirer": "^13.2.0", "is-installed-globally": "^1.0.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", "issue-regex": "^4.3.0", "listr": "^0.14.3", "listr-input": "^0.2.1", - "log-symbols": "^7.0.0", - "meow": "^13.2.0", + "log-symbols": "^7.0.1", + "meow": "^14.0.0", "new-github-release-url": "^2.0.0", "npm-name": "^8.0.0", "onetime": "^7.0.0", - "open": "^10.0.4", - "p-memoize": "^7.1.1", - "p-timeout": "^6.1.4", - "path-exists": "^5.0.0", + "open": "^11.0.0", + "p-memoize": "^8.0.0", + "p-timeout": "^7.0.1", "package-directory": "^8.0.0", - "read-package-up": "^11.0.0", - "read-pkg": "^9.0.1", - "rxjs": "^7.8.1", - "semver": "^7.6.0", + "path-exists": "^5.0.0", + "read-package-up": "^12.0.0", + "read-pkg": "^10.0.0", + "rxjs": "^7.8.2", + "semver": "^7.7.3", "symbol-observable": "^4.0.0", - "terminal-link": "^3.0.0", + "terminal-link": "^5.0.0", "update-notifier": "^7.3.1" }, "devDependencies": { - "@sindresorhus/is": "^7.0.1", - "@types/semver": "^7.5.8", - "ava": "^6.2.0", + "@sindresorhus/is": "^7.2.0", + "@types/semver": "^7.7.1", + "ava": "^6.4.1", "common-tags": "^1.8.2", - "esmock": "^2.7.0", - "fs-extra": "^11.3.0", - "map-obj": "^5.0.2", - "sinon": "^19.0.2", - "strip-ansi": "^7.1.0", + "esmock": "^2.7.3", + "fs-extra": "^11.3.3", + "map-obj": "^6.0.0", + "sinon": "^21.0.1", + "strip-ansi": "^7.1.2", "tempy": "^3.1.0", - "write-package": "^7.1.0", - "xo": "^0.60.0" + "write-package": "^7.2.0", + "xo": "^1.2.3" }, "ava": { "files": [ @@ -92,6 +92,7 @@ "environmentVariables": { "FORCE_HYPERLINK": "1", "HOME": "/tmp", + "NODE_ENV": "test", "GIT_AUTHOR_NAME": "test", "GIT_COMMITTER_NAME": "test", "GIT_AUTHOR_EMAIL": "test@example.com", @@ -101,5 +102,10 @@ "--loader=esmock", "--no-warnings=ExperimentalWarning" ] + }, + "xo": { + "ignores": [ + "test/fixtures" + ] } } diff --git a/source/cli-implementation.js b/source/cli-implementation.js index dfe376d4..78df9402 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -1,6 +1,5 @@ #!/usr/bin/env node -// eslint-disable-next-line import/no-unassigned-import -import 'symbol-observable'; // Important: This needs to be first to prevent weird Observable incompatibilities +import process from 'node:process'; import logSymbols from 'log-symbols'; import meow from 'meow'; import updateNotifier from 'update-notifier'; @@ -134,10 +133,12 @@ async function getOptions() { const runPublish = !flags.releaseDraftOnly && flags.publish && !package_.private; - const availability = runPublish ? await npm.isPackageNameAvailable(package_) : { - isAvailable: false, - isUnknown: false, - }; + const availability = runPublish + ? await npm.isPackageNameAvailable(package_) + : { + isAvailable: false, + isUnknown: false, + }; // Use current (latest) version when 'releaseDraftOnly', otherwise try to use the first argument. const version = flags.releaseDraftOnly ? package_.version : cli.input.at(0); @@ -186,7 +187,7 @@ try { } console.log(); // Prints a newline for readability - const newPackage = await np(options.version, options, {package_, rootDirectory}); + const newPackage = await np(options.version.toString(), options, {package_, rootDirectory}); if (options.preview || options.releaseDraftOnly) { gracefulExit(); @@ -194,6 +195,10 @@ try { console.log(`\n ${newPackage.name} ${newPackage.version} published πŸŽ‰`); } catch (error) { + if (error.name === 'ExitPromptError') { + process.exit(0); + } + console.error(`\n${logSymbols.error} ${error?.stack ?? error}`); gracefulExit(1); } diff --git a/source/index.js b/source/index.js index f2cc410f..fbac4c1d 100644 --- a/source/index.js +++ b/source/index.js @@ -1,5 +1,9 @@ import {execa} from 'execa'; import {deleteAsync} from 'del'; +// NOTE: We intentionally use the original `listr` package instead of `listr2`. +// listr2's DefaultRenderer uses log-update which has known issues with terminal scrolling +// that cause it to overwrite content printed before listr2 started (like our inquirer prompts). +// See: https://github.com/cenk1cenk2/listr2/issues/296 import Listr from 'listr'; import { merge, @@ -178,53 +182,55 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root return exec(cli, arguments_); }, }, - ...options.runPublish ? [ - { - title: 'Publishing package', - skip() { - if (options.preview) { - const command = getPublishCommand(options); - return `[Preview] Command not executed: ${printCommand(command)}.`; - } - }, - /** @type {(context, task) => Listr.ListrTaskResult} */ - task(context, task) { - let hasError = false; + ...options.runPublish + ? [ + { + title: 'Publishing package', + skip() { + if (options.preview) { + const command = getPublishCommand(options); + return `[Preview] Command not executed: ${printCommand(command)}.`; + } + }, + /** @type {(context, task) => Listr.ListrTaskResult} */ + task(context, task) { + let hasError = false; - return from(runPublish(getPublishCommand(options))) - .pipe( - catchError(error => handleNpmError(error, task, otp => { + return from(runPublish(getPublishCommand(options))) + .pipe(catchError(error => handleNpmError(error, task, otp => { context.otp = otp; return runPublish(getPublishCommand({...options, otp})); - })), - ) - .pipe( - // Note: Cannot use `async` here as the `await` will not finish before the error propagates. - catchError(error => { - hasError = true; - return from(rollback()).pipe( - mergeMap(() => throwError(() => new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`))), - catchError(() => throwError(() => new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`))), - ); - }), - finalize(() => { - publishStatus = hasError ? 'FAILED' : 'SUCCESS'; - }), - ); - }, - }, - ...shouldEnable2FA ? [{ - title: 'Enabling two-factor authentication', - async skip() { - if (options.preview) { - const arguments_ = await getEnable2faArguments(package_.name, options); - return `[Preview] Command not executed: npm ${arguments_.join(' ')}.`; - } + }))) + .pipe( + // Note: Cannot use `async` here as the `await` will not finish before the error propagates. + catchError(error => { + hasError = true; + return from(rollback()).pipe( + mergeMap(() => throwError(() => new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`))), + catchError(() => throwError(() => new Error(`Error publishing package:\n${error.message}\n\nThe project was rolled back to its previous state.`))), + ); + }), + finalize(() => { + publishStatus = hasError ? 'FAILED' : 'SUCCESS'; + }), + ); + }, }, - task: (context, task) => enable2fa(task, package_.name, {otp: context.otp}), - }] : [], - ] : [], + ...shouldEnable2FA + ? [{ + title: 'Enabling two-factor authentication', + async skip() { + if (options.preview) { + const arguments_ = await getEnable2faArguments(package_.name, options); + return `[Preview] Command not executed: npm ${arguments_.join(' ')}.`; + } + }, + task: (context, task) => enable2fa(task, package_.name, {otp: context.otp}), + }] + : [], + ] + : [], { title: 'Pushing tags', async skip() { @@ -244,16 +250,18 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root pushedObjects = await git.pushGraceful(isOnGitHub); }, }, - ...options.releaseDraft ? [{ - title: 'Creating release draft on GitHub', - enabled: () => isOnGitHub === true, - skip() { - if (options.preview) { - return '[Preview] GitHub Releases draft will not be opened in preview mode.'; - } - }, - task: () => releaseTaskHelper(options, package_, packageManager), - }] : [], + ...options.releaseDraft + ? [{ + title: 'Creating release draft on GitHub', + enabled: () => isOnGitHub === true, + skip() { + if (options.preview) { + return '[Preview] GitHub Releases draft will not be opened in preview mode.'; + } + }, + task: () => releaseTaskHelper(options, package_, packageManager), + }] + : [], ], { showSubtasks: false, renderer: options.renderer ?? 'default', diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index 55ffc032..15c87d7d 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -20,9 +20,7 @@ export const getEnable2faArguments = async (packageName, options) => { const enable2fa = (packageName, options) => execa('npm', getEnable2faArguments(packageName, options)); const tryEnable2fa = (task, packageName, options) => { - from(enable2fa(packageName, options)).pipe( - catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp}))), - ); + from(enable2fa(packageName, options)).pipe(catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp})))); }; export default tryEnable2fa; diff --git a/source/npm/handle-npm-error.js b/source/npm/handle-npm-error.js index 1ebaefab..abaa3a61 100644 --- a/source/npm/handle-npm-error.js +++ b/source/npm/handle-npm-error.js @@ -23,9 +23,7 @@ const handleNpmError = (error, task, message, executor) => { return executor(otp); }, autoSubmit: value => value.length === 6, - }).pipe( - catchError(error => handleNpmError(error, task, 'OTP was incorrect, try again:', executor)), - ); + }).pipe(catchError(error => handleNpmError(error, task, 'OTP was incorrect, try again:', executor))); } // Attempting to privately publish a scoped package without the correct npm plan diff --git a/source/npm/util.js b/source/npm/util.js index 548b6d10..f51118aa 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -11,19 +11,17 @@ export const version = async () => { return stdout; }; -export const checkConnection = () => pTimeout( - (async () => { - try { - await execa('npm', ['ping']); - return true; - } catch { - throw new Error('Connection to npm registry failed'); - } - })(), { - milliseconds: 15_000, - message: 'Connection to npm registry timed out', - }, -); +export const checkConnection = () => pTimeout((async () => { + try { + await execa('npm', ['ping']); + return true; + } catch { + throw new Error('Connection to npm registry failed'); + } +})(), { + milliseconds: 15_000, + message: 'Connection to npm registry timed out', +}); export const username = async ({externalRegistry}) => { const arguments_ = ['whoami']; @@ -53,7 +51,22 @@ export const login = async ({externalRegistry}) => { arguments_.push('--registry', externalRegistry); } - await execa('npm', arguments_, {stdio: 'inherit'}); + try { + await execa('npm', arguments_, { + stdin: 'inherit', + stdout: 'inherit', + stderr: 'pipe', + }); + } catch (error) { + // User canceled the login prompt + if (error.stderr?.includes('canceled')) { + const cancelError = new Error('Login canceled'); + cancelError.name = 'ExitPromptError'; + throw cancelError; + } + + throw error; + } }; const NPM_DEFAULT_REGISTRIES = new Set([ diff --git a/source/ui.js b/source/ui.js index 90a2cc7a..6fc0e198 100644 --- a/source/ui.js +++ b/source/ui.js @@ -65,8 +65,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch }).join('\n'); const releaseNotes = nextTag => commits.map(commit => - `- ${htmlEscape(commit.message)} ${commit.id}`, - ).join('\n') + `\n\n---\n\n${repoUrl}/compare/${revision}...${nextTag}`; + `- ${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`); @@ -249,7 +248,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { const answers = await inquirer.prompt({ version: { - type: 'list', + type: 'select', message: 'Select SemVer increment or specify new version', pageSize: SEMVER_INCREMENTS.length + 2, default: 0, @@ -294,7 +293,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { }, }, tag: { - type: 'list', + type: 'select', message: 'How should this pre-release version be tagged in npm?', when: answers => needsPrereleaseTag(answers), async choices() { diff --git a/source/util.js b/source/util.js index 229d6b10..a6379be9 100644 --- a/source/util.js +++ b/source/util.js @@ -34,7 +34,7 @@ const _npRootDirectory = fileURLToPath(new URL('..', import.meta.url)); export const {package_: npPackage, rootDirectory: npRootDirectory} = await readPackage(_npRootDirectory); export const linkifyIssues = (url, message) => { - if (!(url && terminalLink.isSupported)) { + if (!url) { return message; } @@ -50,7 +50,7 @@ export const linkifyIssues = (url, message) => { }; export const linkifyCommit = (url, commit) => { - if (!(url && terminalLink.isSupported)) { + if (!url) { return commit; } @@ -58,7 +58,7 @@ export const linkifyCommit = (url, commit) => { }; export const linkifyCommitRange = (url, commitRange) => { - if (!(url && terminalLink.isSupported)) { + if (!url) { return commitRange; } diff --git a/source/version.js b/source/version.js index 5b1fc999..812698d3 100644 --- a/source/version.js +++ b/source/version.js @@ -141,17 +141,23 @@ export default class Version { return chalk(`{${color} ${major}.${minor}.${patch}-${prereleaseDiff}}`); } - /* eslint-disable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ + /* eslint-disable unicorn/no-nested-ternary */ return ( - this.#diff === 'major' ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}}`) : - this.#diff === 'minor' ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}}`) : - this.#diff === 'patch' ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}}`) : - this.#diff === 'premajor' ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : - this.#diff === 'preminor' ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : - this.#diff === 'prepatch' ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}-{${diffColor} ${prerelease.join('.')}}}`) : - this.#diff === 'prerelease' ? chalk(`{${color} ${major}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : '' + this.#diff === 'major' + ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}}`) + : this.#diff === 'minor' + ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}}`) + : this.#diff === 'patch' + ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}}`) + : this.#diff === 'premajor' + ? chalk(`{${color} {${diffColor} ${major}}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) + : this.#diff === 'preminor' + ? chalk(`{${color} ${major}.{${diffColor} ${minor}}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) + : this.#diff === 'prepatch' + ? chalk(`{${color} ${major}.${minor}.{${diffColor} ${patch}}-{${diffColor} ${prerelease.join('.')}}}`) + : this.#diff === 'prerelease' ? chalk(`{${color} ${major}.${minor}.${patch}-{${diffColor} ${prerelease.join('.')}}}`) : '' ); - /* eslint-enable indent, unicorn/no-nested-ternary, operator-linebreak, no-multi-spaces */ + /* eslint-enable unicorn/no-nested-ternary */ } /** diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js index 2306237d..242af26b 100644 --- a/test/_helpers/mock-inquirer.js +++ b/test/_helpers/mock-inquirer.js @@ -86,7 +86,7 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { /** @param {Answer} input */ const chooseValue = async input => { - t.is(prompt.type, 'list'); + t.is(prompt.type, 'select'); let choices; if (is.asyncFunction(prompt.choices)) { @@ -133,7 +133,7 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { if (is.string(input)) { if (['input'].includes(prompt.type)) { setValue(input); - } else if (['list'].includes(prompt.type)) { + } else if (['select'].includes(prompt.type)) { return chooseValue(input); } else { t.fail('Incorrect input type'); diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js index f80237ef..8e48cf31 100644 --- a/test/_helpers/stub-execa.js +++ b/test/_helpers/stub-execa.js @@ -4,6 +4,16 @@ import esmock from 'esmock'; import sinon from 'sinon'; import {execa} from 'execa'; +// Default stubs for common commands that should pass by default +const defaultCommands = [ + {command: 'npm --version', stdout: '10.0.0'}, + {command: 'npm ping', stdout: ''}, + {command: 'git version', stdout: 'git version 2.40.0'}, + {command: 'git ls-remote origin HEAD', stdout: 'abc123\tHEAD'}, + {command: 'git fetch', stdout: ''}, + {command: 'git config --get tag.gpgSign', stdout: ''}, +]; + /** Stubs `execa` to return a specific result when called with the given commands. @@ -16,7 +26,8 @@ Resolves or throws the given result. const makeExecaStub = commands => { const stub = sinon.stub(); - for (const result of commands) { + // Apply default commands first, then user commands (which can override defaults) + for (const result of [...defaultCommands, ...commands]) { const [command, ...commandArguments] = result.command.split(' '); const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); diff --git a/test/config.js b/test/config.js index c337ac28..085a580e 100644 --- a/test/config.js +++ b/test/config.js @@ -51,46 +51,79 @@ const useLocalBinary = test.macro(async (t, packageDirectory, source) => { } }); -test('returns config from home directory when global binary is used and .np-config-json exists in home directory', - useGlobalBinary, 'homedir1', 'homedir/.np-config.json', +test( + 'returns config from home directory when global binary is used and .np-config-json exists in home directory', + useGlobalBinary, + 'homedir1', + 'homedir/.np-config.json', ); -test('returns config from home directory when global binary is used and `.np-config.js` as CJS exists in home directory', - useGlobalBinary, 'homedir2', 'homedir/.np-config.js', +test( + 'returns config from home directory when global binary is used and `.np-config.js` as CJS exists in home directory', + useGlobalBinary, + 'homedir2', + 'homedir/.np-config.js', ); -test('returns config from home directory when global binary is used and `.np-config.cjs` exists in home directory', - useGlobalBinary, 'homedir3', 'homedir/.np-config.cjs', +test( + 'returns config from home directory when global binary is used and `.np-config.cjs` exists in home directory', + useGlobalBinary, + 'homedir3', + 'homedir/.np-config.cjs', ); -test('returns config from home directory when global binary is used and `.np-config.js` as ESM exists in home directory', - useGlobalBinary, 'homedir4', 'homedir/.np-config.js', +test( + 'returns config from home directory when global binary is used and `.np-config.js` as ESM exists in home directory', + useGlobalBinary, + 'homedir4', + 'homedir/.np-config.js', ); -test('returns config from home directory when global binary is used and `.np-config.mjs` exists in home directory', - useGlobalBinary, 'homedir5', 'homedir/.np-config.mjs', +test( + 'returns config from home directory when global binary is used and `.np-config.mjs` exists in home directory', + useGlobalBinary, + 'homedir5', + 'homedir/.np-config.mjs', ); -test('returns config from package directory when local binary is used and `package.json` exists in package directory', - useLocalBinary, 'pkg-dir', 'package.json', +test( + 'returns config from package directory when local binary is used and `package.json` exists in package directory', + useLocalBinary, + 'pkg-dir', + 'package.json', ); -test('returns config from package directory when local binary is used and `.np-config.json` exists in package directory', - useLocalBinary, 'local1', 'packagedir/.np-config.json', +test( + 'returns config from package directory when local binary is used and `.np-config.json` exists in package directory', + useLocalBinary, + 'local1', + 'packagedir/.np-config.json', ); -test('returns config from package directory when local binary is used and `.np-config.js` as CJS exists in package directory', - useLocalBinary, 'local2', 'packagedir/.np-config.js', +test( + 'returns config from package directory when local binary is used and `.np-config.js` as CJS exists in package directory', + useLocalBinary, + 'local2', + 'packagedir/.np-config.js', ); -test('returns config from package directory when local binary is used and `.np-config.cjs` exists in package directory', - useLocalBinary, 'local3', 'packagedir/.np-config.cjs', +test( + 'returns config from package directory when local binary is used and `.np-config.cjs` exists in package directory', + useLocalBinary, + 'local3', + 'packagedir/.np-config.cjs', ); -test('returns config from package directory when local binary is used and `.np-config.js` as ESM exists in package directory', - useLocalBinary, 'local4', 'packagedir/.np-config.js', +test( + 'returns config from package directory when local binary is used and `.np-config.js` as ESM exists in package directory', + useLocalBinary, + 'local4', + 'packagedir/.np-config.js', ); -test('returns config from package directory when local binary is used and `.np-config.mjs` exists in package directory', - useLocalBinary, 'local5', 'packagedir/.np-config.mjs', +test( + 'returns config from package directory when local binary is used and `.np-config.mjs` exists in package directory', + useLocalBinary, + 'local5', + 'packagedir/.np-config.mjs', ); diff --git a/test/git-util/push-graceful.js b/test/git-util/push-graceful.js index 97b6c14a..89d6518b 100644 --- a/test/git-util/push-graceful.js +++ b/test/git-util/push-graceful.js @@ -8,9 +8,7 @@ test('succeeds', createFixture, [{ command: 'git push --follow-tags', exitCode: 0, }], async ({t, testedModule: {pushGraceful}}) => { - await t.notThrowsAsync( - pushGraceful(), - ); + await t.notThrowsAsync(pushGraceful()); }); test('fails w/ remote on GitHub and bad branch permission', createFixture, [ @@ -33,8 +31,6 @@ test('throws', createFixture, [{ command: 'git push --follow-tags', exitCode: 1, }], async ({t, testedModule: {pushGraceful}}) => { - await t.throwsAsync( - pushGraceful(false), - ); + await t.throwsAsync(pushGraceful(false)); }); diff --git a/test/git-util/verify-current-branch-is-release-branch.js b/test/git-util/verify-current-branch-is-release-branch.js index fa6165b6..df2204cb 100644 --- a/test/git-util/verify-current-branch-is-release-branch.js +++ b/test/git-util/verify-current-branch-is-release-branch.js @@ -7,9 +7,7 @@ const createFixture = _createFixture('../../source/git-util.js'); test('on release branch', createFixture, async ({$$}) => { await $$`git switch -c unicorn`; }, async ({t, testedModule: {verifyCurrentBranchIsReleaseBranch}}) => { - await t.notThrowsAsync( - verifyCurrentBranchIsReleaseBranch('unicorn'), - ); + await t.notThrowsAsync(verifyCurrentBranchIsReleaseBranch('unicorn')); }); test('not on release branch', createFixture, async ({$$}) => { diff --git a/test/git-util/verify-recent-git-version.js b/test/git-util/verify-recent-git-version.js index 2a45601e..55e54182 100644 --- a/test/git-util/verify-recent-git-version.js +++ b/test/git-util/verify-recent-git-version.js @@ -8,9 +8,7 @@ test('satisfied', createFixture, [{ command: 'git version', stdout: 'git version 2.12.0', // One higher than minimum }], async ({t, testedModule: {verifyRecentGitVersion}}) => { - await t.notThrowsAsync( - verifyRecentGitVersion(), - ); + await t.notThrowsAsync(verifyRecentGitVersion()); }); test('not satisfied', createFixture, [{ diff --git a/test/git-util/verify-remote-history-is-clean.js b/test/git-util/verify-remote-history-is-clean.js index c5b10559..8071cebd 100644 --- a/test/git-util/verify-remote-history-is-clean.js +++ b/test/git-util/verify-remote-history-is-clean.js @@ -58,15 +58,11 @@ test('clean fetched remote history', createStubFixture, [ stdout: '0', // No changes }, ], async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { - await t.notThrowsAsync( - verifyRemoteHistoryIsClean(), - ); + await t.notThrowsAsync(verifyRemoteHistoryIsClean()); }); test('no remote', createIntegrationFixture, async () => { // }, async ({t, testedModule: {verifyRemoteHistoryIsClean}}) => { - await t.notThrowsAsync( - verifyRemoteHistoryIsClean(), - ); + await t.notThrowsAsync(verifyRemoteHistoryIsClean()); }); diff --git a/test/git-util/verify-remote-is-valid.js b/test/git-util/verify-remote-is-valid.js index fce75912..fea280b4 100644 --- a/test/git-util/verify-remote-is-valid.js +++ b/test/git-util/verify-remote-is-valid.js @@ -12,9 +12,7 @@ test('has remote', createStubFixture, [{ command: 'git ls-remote origin HEAD', exitCode: 0, }], async ({t, testedModule: {verifyRemoteIsValid}}) => { - await t.notThrowsAsync( - verifyRemoteIsValid(), - ); + await t.notThrowsAsync(verifyRemoteIsValid()); }); test('no remote', createIntegrationFixture, async () => { diff --git a/test/git-util/verify-tag-does-not-exist-on-remote.js b/test/git-util/verify-tag-does-not-exist-on-remote.js index 216c9d02..2827d4b2 100644 --- a/test/git-util/verify-tag-does-not-exist-on-remote.js +++ b/test/git-util/verify-tag-does-not-exist-on-remote.js @@ -20,7 +20,5 @@ test('does not exist', createFixture, [{ stderr: '', stdout: '', }], async ({t, testedModule: {verifyTagDoesNotExistOnRemote}}) => { - await t.notThrowsAsync( - verifyTagDoesNotExistOnRemote('v0.0.0'), - ); + await t.notThrowsAsync(verifyTagDoesNotExistOnRemote('v0.0.0')); }); diff --git a/test/git-util/verify-working-tree-is-clean.js b/test/git-util/verify-working-tree-is-clean.js index d1fc0774..90b1e8b2 100644 --- a/test/git-util/verify-working-tree-is-clean.js +++ b/test/git-util/verify-working-tree-is-clean.js @@ -9,9 +9,7 @@ test('clean', createFixture, async ({t, $$}) => { await $$`git add .`; await $$`git commit -m "added"`; }, async ({t, testedModule: {verifyWorkingTreeIsClean}}) => { - await t.notThrowsAsync( - verifyWorkingTreeIsClean(), - ); + await t.notThrowsAsync(verifyWorkingTreeIsClean()); }); test('not clean', createFixture, async ({t}) => { diff --git a/test/index.js b/test/index.js index 5c64cd52..825866aa 100644 --- a/test/index.js +++ b/test/index.js @@ -4,7 +4,6 @@ import sinon from 'sinon'; import esmock from 'esmock'; import {npmConfig as packageManager} from '../source/package-manager/configs.js'; import * as util from '../source/util.js'; -import np from '../source/index.js'; const defaultOptions = { cleanup: true, @@ -21,27 +20,38 @@ const defaultOptions = { const npPackageResult = await util.readPackage(); +const getNpMock = async () => esmock('../source/index.js', {}, { + execa: {execa: sinon.stub().resolves({stdout: '10.0.0', stderr: ''})}, + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + verifyCurrentBranchIsReleaseBranch: sinon.stub(), + verifyRemoteHistoryIsClean: sinon.stub(), + verifyRemoteIsValid: sinon.stub(), + verifyRecentGitVersion: sinon.stub(), + fetch: sinon.stub(), + verifyTagDoesNotExistOnRemote: sinon.stub(), + }, + '../source/npm/util.js': { + ...await import('../source/npm/util.js'), + checkConnection: sinon.stub().resolves(), + }, +}); + const npFails = test.macro(async (t, inputs, message) => { + const npMock = await getNpMock(); await t.throwsAsync( - Promise.all(inputs.map(input => np(input, defaultOptions, npPackageResult))), + Promise.all(inputs.map(input => npMock(input, defaultOptions, npPackageResult))), {message}, ); }); -test('version is invalid', npFails, - ['foo', '4.x.3'], - /New version (?:foo|4\.x\.3) should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version\./, -); +test('version is invalid', npFails, ['foo', '4.x.3'], /New version (?:foo|4\.x\.3) should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version\./); -test('version is pre-release', npFails, - ['premajor', 'preminor', 'prepatch', 'prerelease', '100.0.0-0', '100.0.0-beta'], - 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag', -); +test('version is pre-release', npFails, ['premajor', 'preminor', 'prepatch', 'prerelease', '100.0.0-0', '100.0.0-beta'], 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'); -test('errors on too low version', npFails, - ['1.0.0', '1.0.0-beta'], - /New version 1\.0\.0(?:-beta)? should be higher than current version \d+\.\d+\.\d+/, -); +test('errors on too low version', npFails, ['1.0.0', '1.0.0-beta'], /New version 1\.0\.0(?:-beta)? should be higher than current version \d+\.\d+\.\d+/); const fakeExecaReturn = () => Object.assign( Promise.resolve({pipe: sinon.stub()}), diff --git a/test/npm/enable-2fa.js b/test/npm/enable-2fa.js index e6d0dc2d..88e69c1a 100644 --- a/test/npm/enable-2fa.js +++ b/test/npm/enable-2fa.js @@ -15,31 +15,25 @@ for (const {version, accessArgs} of npmVersionFixtures) { stdout: version, }]; - test(`npm v${version} - no options`, createFixture, npmVersionCommand, - async ({t, testedModule: {getEnable2faArguments}}) => { - t.deepEqual( - await getEnable2faArguments('np'), - [...accessArgs, 'np'], - ); - }, - ); + test(`npm v${version} - no options`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArguments}}) => { + t.deepEqual( + await getEnable2faArguments('np'), + [...accessArgs, 'np'], + ); + }); - test(`npm v${version} - options, no otp`, createFixture, npmVersionCommand, - async ({t, testedModule: {getEnable2faArguments}}) => { - t.deepEqual( - await getEnable2faArguments('np', {confirm: true}), - [...accessArgs, 'np'], - ); - }, - ); + test(`npm v${version} - options, no otp`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArguments}}) => { + t.deepEqual( + await getEnable2faArguments('np', {confirm: true}), + [...accessArgs, 'np'], + ); + }); - test(`npm v${version} - options, with otp`, createFixture, npmVersionCommand, - async ({t, testedModule: {getEnable2faArguments}}) => { - t.deepEqual( - await getEnable2faArguments('np', {otp: '123456'}), - [...accessArgs, 'np', '--otp', '123456'], - ); - }, - ); + test(`npm v${version} - options, with otp`, createFixture, npmVersionCommand, async ({t, testedModule: {getEnable2faArguments}}) => { + t.deepEqual( + await getEnable2faArguments('np', {otp: '123456'}), + [...accessArgs, 'np', '--otp', '123456'], + ); + }); } diff --git a/test/npm/util/verify-recent-npm-version.js b/test/npm/util/verify-recent-npm-version.js index c471625c..06ed16da 100644 --- a/test/npm/util/verify-recent-npm-version.js +++ b/test/npm/util/verify-recent-npm-version.js @@ -8,9 +8,7 @@ test('satisfied', createFixture, [{ command: 'npm --version', stdout: '99.20.0', // Higher than minimum }], async ({t, testedModule: npm}) => { - await t.notThrowsAsync( - npm.verifyRecentNpmVersion(), - ); + await t.notThrowsAsync(npm.verifyRecentNpmVersion()); }); test('not satisfied', createFixture, [{ diff --git a/test/tasks/git-tasks.js b/test/tasks/git-tasks.js index 89d4aef7..6fbecd27 100644 --- a/test/tasks/git-tasks.js +++ b/test/tasks/git-tasks.js @@ -56,9 +56,7 @@ test.serial('should not fail when current branch not master and publishing from stdout: '0', }, ], async ({t, testedModule: gitTasks}) => { - await t.notThrowsAsync( - run(gitTasks({anyBranch: true})), - ); + await t.notThrowsAsync(run(gitTasks({anyBranch: true}))); assertTaskDoesntExist(t, 'Check current branch'); }); @@ -95,9 +93,7 @@ test.serial('should not fail when no remote set up', createFixture, [ stderr: 'fatal: no upstream configured for branch \'master\'', }, ], async ({t, testedModule: gitTasks}) => { - await t.notThrowsAsync( - run(gitTasks({branch: 'master'})), - ); + await t.notThrowsAsync(run(gitTasks({branch: 'master'}))); }); test.serial('should fail when remote history differs and changes are fetched', createFixture, [ @@ -178,7 +174,5 @@ test.serial('checks should pass when publishing from master, working tree is cle stdout: '0', }, ], async ({t, testedModule: gitTasks}) => { - await t.notThrowsAsync( - run(gitTasks({branch: 'master'})), - ); + await t.notThrowsAsync(run(gitTasks({branch: 'master'}))); }); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index a92ae869..3856cde8 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -1,7 +1,6 @@ import process from 'node:process'; import test from 'ava'; import {npmConfig, yarnConfig} from '../../source/package-manager/configs.js'; -import actualPrerequisiteTasks from '../../source/prerequisite-tasks.js'; import {npPackage} from '../../source/util.js'; import {SilentRenderer} from '../_helpers/listr-renderer.js'; import {_createFixture} from '../_helpers/stub-execa.js'; @@ -10,8 +9,13 @@ import {run, assertTaskFailed, assertTaskDisabled} from '../_helpers/listr.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/prerequisite-tasks.js', import.meta.url); +test.beforeEach(() => { + process.env.NODE_ENV = 'test'; +}); + test.afterEach(() => { SilentRenderer.clearTasks(); + process.env.NODE_ENV = 'test'; }); test.serial('public-package published on npm registry: should fail when npm registry not pingable', createFixture, [{ @@ -33,9 +37,7 @@ test.serial('private package: should disable task pinging npm registry', createF command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig)), - ); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig))); assertTaskDisabled(t, 'Ping npm registry'); }); @@ -44,9 +46,7 @@ test.serial('external registry: should disable task pinging npm registry', creat command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, npmConfig)), - ); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, npmConfig))); assertTaskDisabled(t, 'Ping npm registry'); }); @@ -97,7 +97,7 @@ test.serial('should fail when user is not authenticated at npm registry', create stdout: 'sindresorhus', }, { - command: 'npm access list collaborators test', + command: 'npm access list collaborators test --json', stdout: '{"sindresorhus": "read"}', }, ], async ({t, testedModule: prerequisiteTasks}) => { @@ -143,9 +143,7 @@ test.serial('private package: should disable task `verify user is authenticated` }], async ({t, testedModule: prerequisiteTasks}) => { process.env.NODE_ENV = 'P'; - await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig)), - ); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig))); process.env.NODE_ENV = 'test'; @@ -180,27 +178,27 @@ test.serial('should fail when git remote does not exist', createFixture, [{ assertTaskFailed(t, 'Check git remote'); }); -test.serial('should fail when version is invalid', async t => { +test.serial('should fail when version is invalid', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(actualPrerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'New version DDD should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version.'}, ); assertTaskFailed(t, 'Validate version'); }); -test.serial('should fail when version is lower than latest version', async t => { +test.serial('should fail when version is lower than latest version', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(actualPrerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'New version 0.1.0 should be higher than current version 1.0.0.'}, ); assertTaskFailed(t, 'Validate version'); }); -test.serial('should fail when prerelease version of public package without dist tag given', async t => { +test.serial('should fail when prerelease version of public package without dist tag given', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(actualPrerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {}, npmConfig)), {message: 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'}, ); @@ -211,18 +209,14 @@ test.serial('should not fail when prerelease version of public package with dist command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {tag: 'pre'}, npmConfig)), - ); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {tag: 'pre'}, npmConfig))); }); test.serial('should not fail when prerelease version of private package without dist tag given', createFixture, [{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig)), - ); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig))); }); test.serial('should fail when git tag already exists', createFixture, [{ @@ -241,7 +235,5 @@ test.serial('checks should pass', createFixture, [{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), - ); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); }); diff --git a/test/util/hyperlinks.js b/test/util/hyperlinks.js index 01fe1b74..fc6c72f9 100644 --- a/test/util/hyperlinks.js +++ b/test/util/hyperlinks.js @@ -32,11 +32,11 @@ test('linkifyIssues returns raw message if url is not provided', verifyLinks, { t.is(linkifyIssues(undefined, message), message); }); -test('linkifyIssues returns raw message and url if terminalLink is not supported', verifyLinks, { +test('linkifyIssues returns raw message if terminalLink is not supported', verifyLinks, { linksSupported: false, }, ({t, util: {linkifyIssues}}) => { const message = 'Commit message - fixes #6'; - t.is(linkifyIssues(MOCK_REPO_URL, message), message); + t.is(linkifyIssues(MOCK_REPO_URL, message), `${message} ${MOCK_REPO_URL}/issues/6`); }); test('linkifyCommit correctly links commits', verifyLinks, { @@ -51,10 +51,10 @@ test('linkifyCommit returns raw commit hash if url is not provided', verifyLinks t.is(linkifyCommit(undefined, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); }); -test('linkifyCommit returns raw commit hash and url if terminalLink is not supported', verifyLinks, { +test('linkifyCommit returns raw commit hash if terminalLink is not supported', verifyLinks, { linksSupported: false, }, ({t, util: {linkifyCommit}}) => { - t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), MOCK_COMMIT_HASH); + t.is(linkifyCommit(MOCK_REPO_URL, MOCK_COMMIT_HASH), `${MOCK_COMMIT_HASH} ${MOCK_REPO_URL}/commit/${MOCK_COMMIT_HASH}`); }); test('linkifyCommitRange returns raw commitRange if url is not provided', verifyLinks, { @@ -63,10 +63,10 @@ test('linkifyCommitRange returns raw commitRange if url is not provided', verify t.is(linkifyCommitRange(undefined, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); }); -test('linkifyCommitRange returns raw commitRange and url if terminalLink is not supported', verifyLinks, { +test('linkifyCommitRange returns raw commitRange if terminalLink is not supported', verifyLinks, { linksSupported: false, }, ({t, util: {linkifyCommitRange}}) => { - t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), MOCK_COMMIT_RANGE); + t.is(linkifyCommitRange(MOCK_REPO_URL, MOCK_COMMIT_RANGE), `${MOCK_COMMIT_RANGE} ${MOCK_REPO_URL}/compare/${MOCK_COMMIT_RANGE}`); }); test('linkifyCommitRange correctly links commit range', verifyLinks, { diff --git a/test/util/validate-engine-version-satisfies.js b/test/util/validate-engine-version-satisfies.js index 9dc575e5..58e84e40 100644 --- a/test/util/validate-engine-version-satisfies.js +++ b/test/util/validate-engine-version-satisfies.js @@ -4,9 +4,8 @@ import {validateEngineVersionSatisfies, npPackage} from '../../source/util.js'; const testEngineRanges = test.macro((t, engine, {above, below}) => { const range = npPackage.engines[engine]; - t.notThrows( - () => validateEngineVersionSatisfies(engine, above), // Above minimum - ); + // Above minimum + t.notThrows(() => validateEngineVersionSatisfies(engine, above)); t.throws( () => validateEngineVersionSatisfies(engine, below), // Below minimum From 4f910a2b937d5948cdf38bef287003324b92c16f Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 2 Aug 2023 15:39:55 +1200 Subject: [PATCH 119/180] Add `--no-release-notes` flag Closes #709 Fixes #708 Co-authored-by: Sindre Sorhus --- readme.md | 2 ++ source/cli-implementation.js | 5 +++++ source/release-task-helper.js | 2 +- source/ui.js | 12 ++++++------ test/cli.js | 1 + test/release-task-helper.js | 7 ++++++- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index 6a68e88c..97df89e8 100644 --- a/readme.md +++ b/readme.md @@ -68,6 +68,7 @@ $ np --help --contents Subdirectory to publish --no-release-draft Skips opening a GitHub release draft --release-draft-only Only opens a GitHub release draft for the latest published version + --no-release-notes Skips generating release notes when opening a GitHub release draft --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) @@ -103,6 +104,7 @@ Currently, these are the flags you can configure: - `tag` - Publish under a given dist-tag (`latest` by default). - `contents` - Subdirectory to publish (`.` by default). - `releaseDraft` - Open a GitHub release draft after releasing (`true` by default). +- `releaseNotes` - Auto-generate release notes when opening a GitHub release draft (`true` by default). - `testScript` - Name of npm run script to run tests before publishing (`test` by default). - `2fa` - Enable 2FA on new packages (`true` by default) (setting this to `false` is not recommended). - `message` - The commit message used for the version bump. Any `%s` in the string will be replaced with the new version. By default, npm uses `%s` and Yarn uses `v%s`. diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 78df9402..bd9dd9f3 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -35,6 +35,7 @@ const cli = meow(` --contents Subdirectory to publish --no-release-draft Skips opening a GitHub release draft --release-draft-only Only opens a GitHub release draft for the latest published version + --no-release-notes Skips generating release notes when opening a GitHub release draft --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) @@ -78,6 +79,10 @@ const cli = meow(` releaseDraftOnly: { type: 'boolean', }, + releaseNotes: { + type: 'boolean', + default: true, + }, tag: { type: 'string', }, diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 5ddc80a8..a8bef790 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -13,7 +13,7 @@ const releaseTaskHelper = async (options, package_, packageManager) => { const url = newGithubReleaseUrl({ repoUrl: options.repoUrl, tag, - body: options.releaseNotes(tag), + body: options.releaseNotes ? options.generateReleaseNotes(tag) : '', isPrerelease: newVersion.isPrerelease(), }); diff --git a/source/ui.js b/source/ui.js index 6fc0e198..1a19d6da 100644 --- a/source/ui.js +++ b/source/ui.js @@ -22,7 +22,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch return { hasCommits: false, hasUnreleasedCommits: false, - releaseNotes() {}, + generateReleaseNotes() {}, }; } @@ -64,7 +64,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch return `- ${commitMessage} ${commitId}`; }).join('\n'); - const releaseNotes = nextTag => commits.map(commit => + const generateReleaseNotes = 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); @@ -73,7 +73,7 @@ const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch return { hasCommits: true, hasUnreleasedCommits, - releaseNotes, + generateReleaseNotes, }; }; @@ -154,7 +154,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { } const useLatestTag = !options.releaseDraftOnly; - const {hasCommits, hasUnreleasedCommits, releaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag, releaseBranch); + const {hasCommits, hasUnreleasedCommits, generateReleaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag, releaseBranch); if (hasUnreleasedCommits && options.releaseDraftOnly) { const answers = await inquirer.prompt({ @@ -179,7 +179,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { ...options, confirm: true, repoUrl, - releaseNotes, + generateReleaseNotes, }; } @@ -340,7 +340,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { publishScoped: alreadyPublicScoped || answers.publishScoped, confirm: true, repoUrl, - releaseNotes, + generateReleaseNotes, }; }; diff --git a/test/cli.js b/test/cli.js index 35878746..b2c8aa12 100644 --- a/test/cli.js +++ b/test/cli.js @@ -27,6 +27,7 @@ test('flags: --help', cliPasses, cli, '--help', [ '--contents Subdirectory to publish', '--no-release-draft Skips opening a GitHub release draft', '--release-draft-only Only opens a GitHub release draft for the latest published version', + '--no-release-notes Skips generating release notes when opening a GitHub release draft', '--test-script Name of npm run script to run tests before publishing (default: test)', '--no-2fa Don\'t enable 2FA on new packages (not recommended)', '--message Version bump commit message, \'%s\' will be replaced with version (default: \'%s\' with npm and \'v%s\' with yarn)', diff --git a/test/release-task-helper.js b/test/release-task-helper.js index a20ee673..7a2ea1c7 100644 --- a/test/release-task-helper.js +++ b/test/release-task-helper.js @@ -16,7 +16,12 @@ const verifyRelease = test.macro(async (t, {oldVersion, newVersion, prefixes = { }); await releaseTaskHelper( - {version: newVersion, repoUrl, releaseNotes: sinon.stub()}, + { + version: newVersion, + repoUrl, + releaseNotes: true, + generateReleaseNotes: sinon.stub(), + }, {version: oldVersion}, ); }); From 3ded31edba2811cdab7bfc69b9d1467d24acafaa Mon Sep 17 00:00:00 2001 From: Wills Bithrey Date: Tue, 20 Jan 2026 10:45:40 +0000 Subject: [PATCH 120/180] Support publishing via OIDC authentication (#772) --- source/npm/oidc.js | 28 +++++++++++++++++++++++ source/prerequisite-tasks.js | 6 +++++ test/_helpers/listr.js | 5 ++++ test/npm/oidc.js | 39 ++++++++++++++++++++++++++++++++ test/tasks/prerequisite-tasks.js | 26 ++++++++++++++++++++- 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 source/npm/oidc.js create mode 100644 test/npm/oidc.js diff --git a/source/npm/oidc.js b/source/npm/oidc.js new file mode 100644 index 00000000..99387afb --- /dev/null +++ b/source/npm/oidc.js @@ -0,0 +1,28 @@ +import process from 'node:process'; + +const oidcProviders = [ + { + id: 'github', + name: 'GitHub Actions', + // See https://github.com/npm/cli/blob/7da8fdd3625dd5541af57052c90fe1eabb41eb96/lib/utils/oidc.js#L49-L67 + // See https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings + validate: () => + process.env.GITHUB_ACTIONS + && process.env.ACTIONS_ID_TOKEN_REQUEST_URL + && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + }, + { + id: 'gitlab', + name: 'GitLab CI', + // See https://github.com/npm/cli/blob/7da8fdd3625dd5541af57052c90fe1eabb41eb96/lib/utils/oidc.js#L37-L47 + validate: () => process.env.GITLAB_CI && process.env.NPM_ID_TOKEN, + }, +]; + +export const getOidcProvider = () => { + for (const provider of oidcProviders) { + if (provider.validate()) { + return provider.id; + } + } +}; diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index f3f11404..c90acf99 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -5,6 +5,7 @@ import Version from './version.js'; import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; +import {getOidcProvider} from './npm/oidc.js'; const prerequisiteTasks = (input, package_, options, packageManager) => { const isExternalRegistry = npm.isExternalRegistry(package_); @@ -26,6 +27,11 @@ const prerequisiteTasks = (input, package_, options, packageManager) => { { title: 'Verify user is authenticated', enabled: () => process.env.NODE_ENV !== 'test' && !package_.private, + skip() { + if (getOidcProvider()) { + return 'Environment support for OIDC authentication detected - Skipping whoami check'; + } + }, async task() { const externalRegistry = isExternalRegistry ? package_.publishConfig.registry : false; const username = await npm.username({externalRegistry}); diff --git a/test/_helpers/listr.js b/test/_helpers/listr.js index 84b10b02..f97b8297 100644 --- a/test/_helpers/listr.js +++ b/test/_helpers/listr.js @@ -18,3 +18,8 @@ export const assertTaskDisabled = (t, taskTitle) => { export const assertTaskDoesntExist = (t, taskTitle) => { t.true(SilentRenderer.tasks.every(task => task.title !== taskTitle), `Task '${taskTitle}' exists!`); }; + +export const assertTaskSkipped = (t, taskTitle) => { + const task = SilentRenderer.tasks.find(task => task.title === taskTitle); + t.true(task.isSkipped(), `Task '${taskTitle}' was not skipped!`); +}; diff --git a/test/npm/oidc.js b/test/npm/oidc.js new file mode 100644 index 00000000..88632efe --- /dev/null +++ b/test/npm/oidc.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import esmock from 'esmock'; + +test('detects GitHub Actions', async t => { + const {getOidcProvider} = await esmock('../../source/npm/oidc.js', { + 'node:process': { + env: { + GITHUB_ACTIONS: 'true', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://example.com', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token', + }, + }, + }); + + t.is(getOidcProvider(), 'github'); +}); + +test('detects GitLab CI', async t => { + const {getOidcProvider} = await esmock('../../source/npm/oidc.js', { + 'node:process': { + env: { + GITLAB_CI: 'true', + NPM_ID_TOKEN: 'token', + }, + }, + }); + + t.is(getOidcProvider(), 'gitlab'); +}); + +test('detects no OIDC', async t => { + const {getOidcProvider} = await esmock('../../source/npm/oidc.js', { + 'node:process': { + env: {}, + }, + }); + + t.is(getOidcProvider(), undefined); +}); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 3856cde8..bfa5593a 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -4,7 +4,12 @@ import {npmConfig, yarnConfig} from '../../source/package-manager/configs.js'; import {npPackage} from '../../source/util.js'; import {SilentRenderer} from '../_helpers/listr-renderer.js'; import {_createFixture} from '../_helpers/stub-execa.js'; -import {run, assertTaskFailed, assertTaskDisabled} from '../_helpers/listr.js'; +import { + run, + assertTaskFailed, + assertTaskDisabled, + assertTaskSkipped, +} from '../_helpers/listr.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/prerequisite-tasks.js', import.meta.url); @@ -237,3 +242,22 @@ test.serial('checks should pass', createFixture, [{ }], async ({t, testedModule: prerequisiteTasks}) => { await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); }); + +test.serial('should skip authentication check when OIDC is detected', createFixture, [{ + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + process.env.NODE_ENV = 'P'; + process.env.GITHUB_ACTIONS = 'true'; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'url'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'token'; + + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); + + delete process.env.GITHUB_ACTIONS; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + process.env.NODE_ENV = 'test'; + + assertTaskSkipped(t, 'Verify user is authenticated'); +}); From a42d97a6d6f2258b5b5a8fb404917204434fcbfe Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 17:49:58 +0700 Subject: [PATCH 121/180] Minor tweaks --- test/tasks/prerequisite-tasks.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index bfa5593a..3b721d8b 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -252,12 +252,14 @@ test.serial('should skip authentication check when OIDC is detected', createFixt process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'url'; process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'token'; - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); + t.teardown(() => { + delete process.env.GITHUB_ACTIONS; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + process.env.NODE_ENV = 'test'; + }); - delete process.env.GITHUB_ACTIONS; - delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; - delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; - process.env.NODE_ENV = 'test'; + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); assertTaskSkipped(t, 'Verify user is authenticated'); }); From 380fd242b3e25d1ab1207339af1eecc5d36dfa4f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 18:00:23 +0700 Subject: [PATCH 122/180] Fix authentication when `publishConfig.registry` is official npm registry Fixes #764 --- source/npm/util.js | 2 +- source/prerequisite-tasks.js | 2 +- test/tasks/prerequisite-tasks.js | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index f51118aa..ecde00c9 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -86,7 +86,7 @@ export const collaborators = async package_ => { const arguments_ = ['access', 'list', 'collaborators', packageName, '--json']; - if (isExternalRegistry(package_)) { + if (package_.publishConfig?.registry) { arguments_.push('--registry', package_.publishConfig.registry); } diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index c90acf99..fe01f6a1 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -33,7 +33,7 @@ const prerequisiteTasks = (input, package_, options, packageManager) => { } }, async task() { - const externalRegistry = isExternalRegistry ? package_.publishConfig.registry : false; + const externalRegistry = package_.publishConfig?.registry; const username = await npm.username({externalRegistry}); const collaborators = await npm.collaborators(package_); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 3b721d8b..79beef86 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -140,6 +140,28 @@ test.serial('should fail when user is not authenticated at external registry', c assertTaskFailed(t, 'Verify user is authenticated'); }); +test.serial('should use publishConfig.registry even when set to official npm registry', createFixture, [ + { + command: 'npm whoami --registry https://registry.npmjs.org/', + stdout: 'sindresorhus', + }, + { + command: 'npm access list collaborators test --json --registry https://registry.npmjs.org/', + stdout: '{"sindresorhus": "read"}', + }, +], async ({t, testedModule: prerequisiteTasks}) => { + process.env.NODE_ENV = 'P'; + + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'https://registry.npmjs.org/'}}, {}, npmConfig)), + {message: 'You do not have write permissions required to publish this package.'}, + ); + + process.env.NODE_ENV = 'test'; + + assertTaskFailed(t, 'Verify user is authenticated'); +}); + test.serial.todo('should not fail if no collaborators'); // Verify user is authenticated test.serial('private package: should disable task `verify user is authenticated`', createFixture, [{ From 15040c8f51c50c6603d9f8b70d39a279573ba83e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 20:57:39 +0700 Subject: [PATCH 123/180] Fix `getNpmPackageAccess` to respect `publishConfig.registry` Fixes #763 --- source/ui.js | 2 +- source/util.js | 12 ++++++-- test/util/get-npm-package-access.js | 45 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 test/util/get-npm-package-access.js diff --git a/source/ui.js b/source/ui.js index 1a19d6da..75e4c3d1 100644 --- a/source/ui.js +++ b/source/ui.js @@ -228,7 +228,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { && !options.tag ); - const alreadyPublicScoped = packageManager.id === 'yarn-berry' && options.runPublish && await util.getNpmPackageAccess(package_.name) === 'public'; + const alreadyPublicScoped = packageManager.id === 'yarn-berry' && options.runPublish && await util.getNpmPackageAccess(package_) === '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. diff --git a/source/util.js b/source/util.js index a6379be9..8262b5cb 100644 --- a/source/util.js +++ b/source/util.js @@ -155,7 +155,13 @@ export const validateEngineVersionSatisfies = (engine, version) => { } }; -export async function getNpmPackageAccess(name) { - const {stdout} = await execa('npm', ['access', 'get', 'status', name, '--json']); - return JSON.parse(stdout)[name]; // Note: returns "private" for non-existent packages +export async function getNpmPackageAccess(package_) { + const arguments_ = ['access', 'get', 'status', package_.name, '--json']; + + if (package_.publishConfig?.registry) { + arguments_.push('--registry', package_.publishConfig.registry); + } + + const {stdout} = await execa('npm', arguments_); + return JSON.parse(stdout)[package_.name]; // Note: returns "private" for non-existent packages } diff --git a/test/util/get-npm-package-access.js b/test/util/get-npm-package-access.js new file mode 100644 index 00000000..87ba7465 --- /dev/null +++ b/test/util/get-npm-package-access.js @@ -0,0 +1,45 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/stub-execa.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/util.js', import.meta.url); + +test('main', createFixture, [{ + command: 'npm access get status @my/pkg --json', + stdout: '{"@my/pkg": "public"}', +}], async ({t, testedModule: {getNpmPackageAccess}}) => { + t.is( + await getNpmPackageAccess({name: '@my/pkg'}), + 'public', + ); +}); + +test('with publishConfig.registry', createFixture, [{ + command: 'npm access get status @my/pkg --json --registry https://registry.npmjs.org/', + stdout: '{"@my/pkg": "public"}', +}], async ({t, testedModule: {getNpmPackageAccess}}) => { + t.is( + await getNpmPackageAccess({ + name: '@my/pkg', + publishConfig: { + registry: 'https://registry.npmjs.org/', + }, + }), + 'public', + ); +}); + +test('with external registry', createFixture, [{ + command: 'npm access get status @my/pkg --json --registry http://my-internal-registry.local', + stdout: '{"@my/pkg": "private"}', +}], async ({t, testedModule: {getNpmPackageAccess}}) => { + t.is( + await getNpmPackageAccess({ + name: '@my/pkg', + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }), + 'private', + ); +}); From fea3eb732988d4dd07963d1ad06ea4c977f4be67 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 21:06:26 +0700 Subject: [PATCH 124/180] Fix incorrect commit range when tags are created out of order Sort tags by semver version instead of git creation date when determining the previous tag for release notes. This fixes the issue where hotfix tags (e.g., v1.0.1 released after v1.2.0) would incorrectly be used as the starting point for the commit range. Fixes #758 --- source/git-util.js | 22 +++++++++++++++---- test/git-util/previous-tag-or-first-commit.js | 18 +++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index c9b775a5..3fc05dc0 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -2,6 +2,7 @@ import path from 'node:path'; import {execa} from 'execa'; import escapeStringRegexp from 'escape-string-regexp'; import ignoreWalker from 'ignore-walk'; +import semver from 'semver'; import * as util from './util.js'; export const latestTag = async () => { @@ -39,19 +40,27 @@ export const readFileFromLastRelease = async file => { return oldFile; }; -/** Returns an array of tags, sorted by creation date in ascending order. */ +/** Returns an array of all tags. */ const tagList = async () => { - const {stdout} = await execa('git', ['tag', '--sort=creatordate']); + const {stdout} = await execa('git', ['tag']); return stdout ? stdout.split('\n') : []; }; +/** Returns an array of tags sorted by semver in ascending order. Non-semver tags are excluded. */ +const tagListSortedBySemver = async () => { + const tags = await tagList(); + return tags + .filter(tag => semver.valid(tag)) + .sort((a, b) => semver.compare(a, b)); +}; + const firstCommit = async () => { const {stdout} = await execa('git', ['rev-list', '--max-parents=0', 'HEAD']); return stdout; }; export const previousTagOrFirstCommit = async () => { - const tags = await tagList(); + const tags = await tagListSortedBySemver(); if (tags.length === 0) { return; @@ -62,9 +71,14 @@ export const previousTagOrFirstCommit = async () => { } try { - // Return the tag before the latest one. + // Return the tag before the latest one (sorted by semver). const latest = await latestTag(); const index = tags.indexOf(latest); + + if (index === -1 || index === 0) { + return firstCommit(); + } + return tags[index - 1]; } catch { // Fallback to the first commit. diff --git a/test/git-util/previous-tag-or-first-commit.js b/test/git-util/previous-tag-or-first-commit.js index 53287bbd..7347a50f 100644 --- a/test/git-util/previous-tag-or-first-commit.js +++ b/test/git-util/previous-tag-or-first-commit.js @@ -42,4 +42,22 @@ test('multiple tags', createFixture, async ({t, $$}) => { t.is(result, 'v3.0.0'); }); +test('tags created out of order - should sort by semver not creation date', createFixture, async ({t, $$}) => { + // Create tags out of semver order (simulating a hotfix scenario) + await $$`git tag v1.0.0`; + await t.context.commitNewFile(); + await $$`git tag v1.2.0`; + await t.context.commitNewFile(); + await $$`git tag v1.2.1`; + await t.context.commitNewFile(); + // Create a hotfix tag for an older version (created after v1.2.1 but semver is lower) + await $$`git tag v1.0.1`; + await t.context.commitNewFile(); + await $$`git tag v1.2.2`; +}, async ({t, testedModule: {previousTagOrFirstCommit}}) => { + // Should return v1.2.1 (semver previous), not v1.0.1 (creation date previous) + const result = await previousTagOrFirstCommit(); + t.is(result, 'v1.2.1'); +}); + test.todo('test fallback case'); From 521ecfce697b6606cd0487f1732fefccc8d97249 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 21:10:43 +0700 Subject: [PATCH 125/180] Fix `contents` option being interpreted as package name Fixes #604 --- source/npm/publish.js | 8 +++++++- test/npm/publish.js | 25 +++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/source/npm/publish.js b/source/npm/publish.js index f25ce2ee..2cb61a21 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -1,10 +1,16 @@ +import path from 'node:path'; import {execa} from 'execa'; export const getPackagePublishArguments = options => { const arguments_ = ['publish']; if (options.contents) { - arguments_.push(options.contents); + // Normalize to explicit relative path so npm doesn't interpret it as a package name + const contents = path.isAbsolute(options.contents) || options.contents.startsWith('.') + ? options.contents + : `./${options.contents}`; + + arguments_.push(contents); } if (options.tag) { diff --git a/test/npm/publish.js b/test/npm/publish.js index 316747fa..376e60a2 100644 --- a/test/npm/publish.js +++ b/test/npm/publish.js @@ -8,10 +8,31 @@ test('no options set', t => { ); }); -test('options.contents', t => { +test('options.contents - normalizes relative path', t => { t.deepEqual( getPackagePublishArguments({contents: 'dist'}), - ['publish', 'dist'], + ['publish', './dist'], + ); +}); + +test('options.contents - preserves ./ prefix', t => { + t.deepEqual( + getPackagePublishArguments({contents: './dist'}), + ['publish', './dist'], + ); +}); + +test('options.contents - preserves ../ prefix', t => { + t.deepEqual( + getPackagePublishArguments({contents: '../dist'}), + ['publish', '../dist'], + ); +}); + +test('options.contents - preserves absolute path', t => { + t.deepEqual( + getPackagePublishArguments({contents: '/absolute/path'}), + ['publish', '/absolute/path'], ); }); From dc62a24830d6324f1513c4cba06e5936951e5a77 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 21:19:53 +0700 Subject: [PATCH 126/180] Add clear error message when `repository` field is missing Fixes #639 --- source/release-task-helper.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/release-task-helper.js b/source/release-task-helper.js index a8bef790..312bf1e5 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -4,6 +4,10 @@ import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; const releaseTaskHelper = async (options, package_, packageManager) => { + if (!options.repoUrl) { + throw new Error('Missing `repository` field in package.json. This is required for creating GitHub releases.'); + } + const newVersion = options.releaseDraftOnly ? new Version(package_.version) : new Version(package_.version).setFrom(options.version.toString(), {prereleasePrefix: await getPreReleasePrefix(packageManager)}); From 88226d71f251a773c158e28f6ce79a363126faec Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 21:39:08 +0700 Subject: [PATCH 127/180] Fix `contents` config option being ignored Fixes #622 --- source/cli-implementation.js | 6 ++++- test/config.js | 22 +++++++++++++++++++ .../config/contents-option/.np-config.json | 3 +++ .../config/contents-option/dist/package.json | 4 ++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/config/contents-option/.np-config.json create mode 100644 test/fixtures/config/contents-option/dist/package.json diff --git a/source/cli-implementation.js b/source/cli-implementation.js index bd9dd9f3..a6ac5436 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -113,7 +113,11 @@ updateNotifier({pkg: cli.pkg}).notify(); /** @typedef {Awaited>['options']} Options */ async function getOptions() { - const {package_, rootDirectory} = await util.readPackage(cli.flags.contents); + // Load config from cwd first to get `contents` option before reading package + const initialConfig = await config(process.cwd()); + const contents = cli.flags.contents ?? initialConfig?.contents; + + const {package_, rootDirectory} = await util.readPackage(contents); const localConfig = await config(rootDirectory); const flags = { diff --git a/test/config.js b/test/config.js index 085a580e..b5d80908 100644 --- a/test/config.js +++ b/test/config.js @@ -1,6 +1,7 @@ import path from 'node:path'; import test from 'ava'; import esmock from 'esmock'; +import {readPackage} from '../source/util.js'; const testedModulePath = '../source/config.js'; @@ -127,3 +128,24 @@ test( 'local5', 'packagedir/.np-config.mjs', ); + +test('`contents` option in config allows reading package from subdirectory', async t => { + const fixtureDirectory = getFixture('contents-option'); + + // Load config from fixture directory (simulates loading from process.cwd()) + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': false, + }); + + const config = await getConfig(fixtureDirectory); + + // Config should have contents option + t.is(config.contents, 'dist'); + + // Using contents from config should read package from subdirectory + const contentsPath = path.join(fixtureDirectory, config.contents); + const {package_, rootDirectory} = await readPackage(contentsPath); + + t.is(package_.name, 'from-dist'); + t.is(rootDirectory, contentsPath); +}); diff --git a/test/fixtures/config/contents-option/.np-config.json b/test/fixtures/config/contents-option/.np-config.json new file mode 100644 index 00000000..9e8e7bcd --- /dev/null +++ b/test/fixtures/config/contents-option/.np-config.json @@ -0,0 +1,3 @@ +{ + "contents": "dist" +} diff --git a/test/fixtures/config/contents-option/dist/package.json b/test/fixtures/config/contents-option/dist/package.json new file mode 100644 index 00000000..a689d59d --- /dev/null +++ b/test/fixtures/config/contents-option/dist/package.json @@ -0,0 +1,4 @@ +{ + "name": "from-dist", + "version": "1.0.0" +} From 6a111535ca226209982164ad4e1f0ae1408a4be6 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 21:41:40 +0700 Subject: [PATCH 128/180] Fix some network calls never timing out Fixes #717 --- package.json | 1 - source/git-util.js | 10 +++--- source/npm/enable-2fa.js | 4 +-- source/npm/util.js | 31 +++++++++++------- source/util.js | 12 +++++-- test/_helpers/integration-test.js | 2 +- test/_helpers/stub-execa.js | 52 +++++++++++++++++++++++++------ test/npm/util/check-connection.js | 14 ++++----- 8 files changed, 87 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 42792e29..e7c039d5 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "onetime": "^7.0.0", "open": "^11.0.0", "p-memoize": "^8.0.0", - "p-timeout": "^7.0.1", "package-directory": "^8.0.0", "path-exists": "^5.0.0", "read-package-up": "^12.0.0", diff --git a/source/git-util.js b/source/git-util.js index 3fc05dc0..016b4fe2 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -5,6 +5,8 @@ import ignoreWalker from 'ignore-walk'; import semver from 'semver'; import * as util from './util.js'; +const gitNetworkTimeout = 120_000; // 2 minutes for remote git operations + export const latestTag = async () => { const {stdout} = await execa('git', ['describe', '--abbrev=0', '--tags']); return stdout; @@ -158,7 +160,7 @@ const hasRemote = async () => { }; const hasUnfetchedChangesFromRemote = async () => { - const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run']); + const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run'], {timeout: gitNetworkTimeout}); // There are no unfetched changes if output is empty. return !possibleNewChanges || possibleNewChanges === ''; @@ -187,14 +189,14 @@ export const verifyRemoteHistoryIsClean = async () => { export const verifyRemoteIsValid = async () => { try { - await execa('git', ['ls-remote', 'origin', 'HEAD']); + await execa('git', ['ls-remote', 'origin', 'HEAD'], {timeout: gitNetworkTimeout}); } catch (error) { throw new Error(error.stderr.replace('fatal:', 'Git fatal error:')); } }; export const fetch = async () => { - await execa('git', ['fetch']); + await execa('git', ['fetch'], {timeout: gitNetworkTimeout}); }; const hasLocalBranch = async branch => { @@ -249,7 +251,7 @@ export const commitLogFromRevision = async revision => { }; const push = async (tagArgument = '--follow-tags') => { - await execa('git', ['push', tagArgument]); + await execa('git', ['push', tagArgument], {timeout: gitNetworkTimeout}); }; export const pushGraceful = async remoteIsOnGitHub => { diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index 15c87d7d..122aee19 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -2,7 +2,7 @@ import {execa} from 'execa'; import {from, catchError} from 'rxjs'; import Version from '../version.js'; import handleNpmError from './handle-npm-error.js'; -import {version as npmVersionCheck} from './util.js'; +import {npmNetworkTimeout, version as npmVersionCheck} from './util.js'; export const getEnable2faArguments = async (packageName, options) => { const npmVersion = await npmVersionCheck(); @@ -17,7 +17,7 @@ export const getEnable2faArguments = async (packageName, options) => { return arguments_; }; -const enable2fa = (packageName, options) => execa('npm', getEnable2faArguments(packageName, options)); +const enable2fa = async (packageName, options) => execa('npm', await getEnable2faArguments(packageName, options), {timeout: npmNetworkTimeout}); const tryEnable2fa = (task, packageName, options) => { from(enable2fa(packageName, options)).pipe(catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp})))); diff --git a/source/npm/util.js b/source/npm/util.js index ecde00c9..5f1886af 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -1,7 +1,6 @@ import path from 'node:path'; import {pathExists} from 'path-exists'; import {execa} from 'execa'; -import pTimeout from 'p-timeout'; import npmName from 'npm-name'; import chalk from 'chalk-template'; import * as util from '../util.js'; @@ -11,17 +10,24 @@ export const version = async () => { return stdout; }; -export const checkConnection = () => pTimeout((async () => { +export const npmNetworkTimeout = 15_000; // 15 seconds for npm registry calls + +const throwIfNpmTimeout = error => { + if (error.timedOut) { + error.message = 'Connection to npm registry timed out'; + throw error; + } +}; + +export const checkConnection = async () => { try { - await execa('npm', ['ping']); + await execa('npm', ['ping'], {timeout: npmNetworkTimeout}); return true; - } catch { + } catch (error) { + throwIfNpmTimeout(error); throw new Error('Connection to npm registry failed'); } -})(), { - milliseconds: 15_000, - message: 'Connection to npm registry timed out', -}); +}; export const username = async ({externalRegistry}) => { const arguments_ = ['whoami']; @@ -31,9 +37,10 @@ export const username = async ({externalRegistry}) => { } try { - const {stdout} = await execa('npm', arguments_); + const {stdout} = await execa('npm', arguments_, {timeout: npmNetworkTimeout}); return stdout; } catch (error) { + throwIfNpmTimeout(error); const isNotLoggedIn = /ENEEDAUTH|E401/.test(error.stderr); const message = isNotLoggedIn ? 'You must be logged in. Use `npm login` and try again.' @@ -91,9 +98,10 @@ export const collaborators = async package_ => { } try { - const {stdout} = await execa('npm', arguments_); + const {stdout} = await execa('npm', arguments_, {timeout: npmNetworkTimeout}); return stdout; } catch (error) { + throwIfNpmTimeout(error); // Ignore non-existing package error if (error.stderr.includes('code E404')) { return false; @@ -108,10 +116,11 @@ export const prereleaseTags = async packageName => { let tags = []; try { - const {stdout} = await execa('npm', ['view', '--json', packageName, 'dist-tags']); + const {stdout} = await execa('npm', ['view', '--json', packageName, 'dist-tags'], {timeout: npmNetworkTimeout}); tags = Object.keys(JSON.parse(stdout)) .filter(tag => tag !== 'latest'); } catch (error) { + throwIfNpmTimeout(error); // HACK: NPM is mixing JSON with plain text errors. Luckily, the error // always starts with 'npm ERR!' (unless you have a debugger attached) // so as a solution, until npm/cli#2740 is fixed, we can remove anything diff --git a/source/util.js b/source/util.js index 8262b5cb..f99a841b 100644 --- a/source/util.js +++ b/source/util.js @@ -162,6 +162,14 @@ export async function getNpmPackageAccess(package_) { arguments_.push('--registry', package_.publishConfig.registry); } - const {stdout} = await execa('npm', arguments_); - return JSON.parse(stdout)[package_.name]; // Note: returns "private" for non-existent packages + try { + const {stdout} = await execa('npm', arguments_, {timeout: npm.npmNetworkTimeout}); + return JSON.parse(stdout)[package_.name]; // Note: returns "private" for non-existent packages + } catch (error) { + if (error.timedOut) { + error.message = 'Connection to npm registry timed out'; + } + + throw error; + } } diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js index f73bf6dc..c6c43fef 100644 --- a/test/_helpers/integration-test.js +++ b/test/_helpers/integration-test.js @@ -57,7 +57,7 @@ export const _createFixture = source => test.macro(async (t, commands, assertion await createIntegrationTest(t, async ({$$, temporaryDirectory}) => { const testedModule = await esmock(source, {}, { 'node:process': {cwd: () => temporaryDirectory}, - execa: {execa: async (...arguments_) => execa(...arguments_, {cwd: temporaryDirectory})}, + execa: {execa: async (command, commandArguments, options = {}) => execa(command, commandArguments, {cwd: temporaryDirectory, ...options})}, }); await commands({t, $$, temporaryDirectory}); diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js index 8e48cf31..c93c8452 100644 --- a/test/_helpers/stub-execa.js +++ b/test/_helpers/stub-execa.js @@ -24,22 +24,54 @@ Resolves or throws the given result. @param {import('execa').ExecaReturnValue[]} commands */ const makeExecaStub = commands => { - const stub = sinon.stub(); - - // Apply default commands first, then user commands (which can override defaults) - for (const result of [...defaultCommands, ...commands]) { + const normalizedCommands = [...defaultCommands, ...commands].map(result => { const [command, ...commandArguments] = result.command.split(' '); + return { + ...result, + command, + commandArguments, + }; + }); + + return sinon.stub().callsFake((command, commandArguments = [], options) => { + for (let index = normalizedCommands.length - 1; index >= 0; index--) { + const result = normalizedCommands[index]; + + if (result.command !== command) { + continue; + } + + if (!areArgumentsEqual(result.commandArguments, commandArguments)) { + continue; + } + + if (!matchesOptions(result.options, options)) { + continue; + } + + const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); - const passes = result.exitCode === 0 || (!result.exitCode && !result.stderr); + if (passes) { + return Promise.resolve(result); + } - if (passes) { - stub.withArgs(command, commandArguments).resolves(result); - } else { - stub.withArgs(command, commandArguments).rejects(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message + return Promise.reject(Object.assign(new Error(), result)); // eslint-disable-line unicorn/error-message } + }); +}; + +const areArgumentsEqual = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]); + +const matchesOptions = (expectedOptions, actualOptions) => { + if (!expectedOptions) { + return true; + } + + if (!actualOptions) { + return false; } - return stub; + return Object.entries(expectedOptions).every(([key, value]) => Object.is(actualOptions[key], value)); }; const stubExeca = commands => { diff --git a/test/npm/util/check-connection.js b/test/npm/util/check-connection.js index 39e32362..7a6480e8 100644 --- a/test/npm/util/check-connection.js +++ b/test/npm/util/check-connection.js @@ -1,6 +1,4 @@ -import {setTimeout} from 'node:timers/promises'; import test from 'ava'; -import esmock from 'esmock'; import {_createFixture} from '../../_helpers/stub-execa.js'; /** @type {ReturnType>} */ @@ -9,6 +7,7 @@ const createFixture = _createFixture('../../../source/npm/util.js', import.meta. test('success', createFixture, [{ command: 'npm ping', exitCode: 0, + options: {timeout: 15_000}, }], async ({t, testedModule: npm}) => { t.true(await npm.checkConnection()); }); @@ -23,12 +22,11 @@ test('fail', createFixture, [{ ); }); -test('timeout', async t => { - t.timeout(16_000); - const npm = await esmock('../../../source/npm/util.js', {}, { - execa: {execa: async () => setTimeout(16_000, {})}, - }); - +test('timeout', createFixture, [{ + command: 'npm ping', + exitCode: 1, + timedOut: true, +}], async ({t, testedModule: npm}) => { await t.throwsAsync( npm.checkConnection(), {message: 'Connection to npm registry timed out'}, From 3e919b30bdd7e4304057166f98c16bb9c47a903d Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 21:57:48 +0700 Subject: [PATCH 129/180] Minor tweaks --- source/git-util.js | 6 +++--- source/npm/enable-2fa.js | 4 +--- test/_helpers/integration-test.js | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index 016b4fe2..7003bf7b 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -162,8 +162,8 @@ const hasRemote = async () => { const hasUnfetchedChangesFromRemote = async () => { const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run'], {timeout: gitNetworkTimeout}); - // There are no unfetched changes if output is empty. - return !possibleNewChanges || possibleNewChanges === ''; + // There are unfetched changes if output is not empty. + return Boolean(possibleNewChanges); }; const isRemoteHistoryClean = async () => { @@ -178,7 +178,7 @@ export const verifyRemoteHistoryIsClean = async () => { return; } - if (!(await hasUnfetchedChangesFromRemote())) { + if (await hasUnfetchedChangesFromRemote()) { throw new Error('Remote history differs. Please run `git fetch` and pull changes.'); } diff --git a/source/npm/enable-2fa.js b/source/npm/enable-2fa.js index 122aee19..25ca84de 100644 --- a/source/npm/enable-2fa.js +++ b/source/npm/enable-2fa.js @@ -19,8 +19,6 @@ export const getEnable2faArguments = async (packageName, options) => { const enable2fa = async (packageName, options) => execa('npm', await getEnable2faArguments(packageName, options), {timeout: npmNetworkTimeout}); -const tryEnable2fa = (task, packageName, options) => { - from(enable2fa(packageName, options)).pipe(catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp})))); -}; +const tryEnable2fa = (task, packageName, options) => from(enable2fa(packageName, options)).pipe(catchError(error => handleNpmError(error, task, otp => enable2fa(packageName, {otp})))); export default tryEnable2fa; diff --git a/test/_helpers/integration-test.js b/test/_helpers/integration-test.js index c6c43fef..247f3405 100644 --- a/test/_helpers/integration-test.js +++ b/test/_helpers/integration-test.js @@ -57,7 +57,7 @@ export const _createFixture = source => test.macro(async (t, commands, assertion await createIntegrationTest(t, async ({$$, temporaryDirectory}) => { const testedModule = await esmock(source, {}, { 'node:process': {cwd: () => temporaryDirectory}, - execa: {execa: async (command, commandArguments, options = {}) => execa(command, commandArguments, {cwd: temporaryDirectory, ...options})}, + execa: {execa: async (command, commandArguments, options = {}) => execa(command, commandArguments, {...options, cwd: temporaryDirectory})}, }); await commands({t, $$, temporaryDirectory}); From 038bdba9b58be492845de71cbf9578b88be6a53b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 22:20:22 +0700 Subject: [PATCH 130/180] Prompt for dist-tag when prerelease version is provided via CLI Fixes #373 --- source/ui.js | 51 +++++++++++++++++++++++++++++++++++++++++ test/ui/prompts/tags.js | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/source/ui.js b/source/ui.js index 75e4c3d1..438c3abf 100644 --- a/source/ui.js +++ b/source/ui.js @@ -174,9 +174,60 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { } // Non-interactive mode - return before prompting + // But if it's a prerelease without a tag, we need to prompt for the tag if (options.version) { + const prereleasePrefix = await util.getPreReleasePrefix(packageManager); + const versionObject = new Version(oldVersion).setFrom(options.version, {prereleasePrefix}); + const needsTag = options.runPublish && versionObject.isPrerelease() && !options.tag; + + if (!needsTag) { + return { + ...options, + confirm: true, + repoUrl, + generateReleaseNotes, + }; + } + + // Prompt for tag only + const answers = await inquirer.prompt({ + tag: { + type: 'select', + message: 'How should this pre-release version be tagged in npm?', + 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, + 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; + }, + }, + }); + return { ...options, + tag: answers.tag || answers.customTag || options.tag, confirm: true, repoUrl, generateReleaseNotes, diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js index 2edc8e07..0938318f 100644 --- a/test/ui/prompts/tags.js +++ b/test/ui/prompts/tags.js @@ -127,3 +127,45 @@ for (const {version, expected} of fixtures) { t.is(tag, 'next'); }); } + +// Test that prerelease versions provided via CLI prompt for tag +for (const {version} of fixtures) { + test(`prompts for tag when ${version} is provided via CLI`, async t => { + const {ui} = await mockInquirer({ + t, answers: {tag: 'next', confirm: true}, mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + prereleaseTags: sinon.stub().resolves(['next']), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves(`v${npPackage.version}`), + commitLogFromRevision: sinon.stub().resolves(''), + }, + './package-manager/index.js': { + findLockfile: sinon.stub().resolves(undefined), + }, + }, + }); + + const results = await ui({ + packageManager, + runPublish: true, + availability: {}, + version, + }, { + package_: { + name: 'foo', + version: '0.0.0', + files: ['*'], + }, + }); + + // Verify that the tag was set via prompt + t.is(results.tag, 'next'); + }); +} From 445cce8ed9299fb72802c12d63d9cf310fa30976 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 22:25:46 +0700 Subject: [PATCH 131/180] Fix CI --- test/ui/prompts/tags.js | 7 +++++++ test/ui/prompts/version.js | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js index 0938318f..ffc3776d 100644 --- a/test/ui/prompts/tags.js +++ b/test/ui/prompts/tags.js @@ -14,6 +14,7 @@ const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { './util.js': { getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), }, './git-util.js': { latestTagOrFirstCommit: sinon.stub().resolves(`v${npPackage.version}`), @@ -22,6 +23,9 @@ const testUi = test.macro(async (t, {version, tags, answers}, assertions) => { './package-manager/index.js': { findLockfile: sinon.stub().resolves(undefined), }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, }, }); @@ -149,6 +153,9 @@ for (const {version} of fixtures) { './package-manager/index.js': { findLockfile: sinon.stub().resolves(undefined), }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, }, }); diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index 45c20c73..4090535a 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -12,11 +12,15 @@ const testUi = test.macro(async (t, {version, answers}, assertions) => { './util.js': { getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), }, './git-util.js': { latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), commitLogFromRevision: sinon.stub().resolves(''), }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, }, }); From a160affdc4c59b196e2c4d3445b13c219cb08687 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 20 Jan 2026 22:49:00 +0700 Subject: [PATCH 132/180] Fix long release notes exceeding GitHub URL limit Fixes #501 --- package.json | 1 + source/release-task-helper.js | 24 +++++++- test/release-task-helper.js | 108 ++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e7c039d5..d5bf0d72 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "chalk": "^5.6.2", "chalk-template": "^1.1.2", + "clipboardy": "^5.0.2", "cosmiconfig": "^9.0.0", "del": "^8.0.1", "escape-goat": "^4.0.0", diff --git a/source/release-task-helper.js b/source/release-task-helper.js index 312bf1e5..02b49ae1 100644 --- a/source/release-task-helper.js +++ b/source/release-task-helper.js @@ -1,8 +1,13 @@ import open from 'open'; import newGithubReleaseUrl from 'new-github-release-url'; +import clipboard from 'clipboardy'; import {getTagVersionPrefix, getPreReleasePrefix} from './util.js'; import Version from './version.js'; +// GitHub has a URL limit of ~8195 characters. We use a conservative limit to be safe. +const URL_LENGTH_LIMIT = 7900; +const CLIPBOARD_PLACEHOLDER = ''; + const releaseTaskHelper = async (options, package_, packageManager) => { if (!options.repoUrl) { throw new Error('Missing `repository` field in package.json. This is required for creating GitHub releases.'); @@ -14,13 +19,28 @@ const releaseTaskHelper = async (options, package_, packageManager) => { const tag = await getTagVersionPrefix(packageManager) + newVersion.toString(); - const url = newGithubReleaseUrl({ + const releaseNotes = options.releaseNotes ? options.generateReleaseNotes(tag) : ''; + + // Try to generate URL with full release notes + let url = newGithubReleaseUrl({ repoUrl: options.repoUrl, tag, - body: options.releaseNotes ? options.generateReleaseNotes(tag) : '', + body: releaseNotes, isPrerelease: newVersion.isPrerelease(), }); + // If the URL is too long, copy release notes to clipboard and use a placeholder + if (url.length > URL_LENGTH_LIMIT) { + await clipboard.write(releaseNotes); + url = newGithubReleaseUrl({ + repoUrl: options.repoUrl, + tag, + body: CLIPBOARD_PLACEHOLDER, + isPrerelease: newVersion.isPrerelease(), + }); + console.log('\nRelease notes are too long for URL. Copied to clipboard instead.'); + } + await open(url); }; diff --git a/test/release-task-helper.js b/test/release-task-helper.js index 7a2ea1c7..5fd3164b 100644 --- a/test/release-task-helper.js +++ b/test/release-task-helper.js @@ -8,6 +8,9 @@ const verifyRelease = test.macro(async (t, {oldVersion, newVersion, prefixes = { /** @type {import('../source/release-task-helper.js')} */ const {default: releaseTaskHelper} = await esmock('../source/release-task-helper.js', import.meta.url, { open: sinon.stub(), + clipboardy: { + write: sinon.stub(), + }, '../source/util.js': { getTagVersionPrefix: async () => prefixes.tag ?? 'v', getPreReleasePrefix: async () => prefixes.preRelease ?? '', @@ -64,3 +67,108 @@ test('prerelease', verifyRelease, { isPrerelease: true, }, }); + +test('uses clipboard when URL is too long', async t => { + const repoUrl = 'https://github.com/sindresorhus/np'; + const clipboardStub = sinon.stub(); + const openStub = sinon.stub(); + const urlCalls = []; + + // Generate a very long release notes string that will exceed the URL limit + const longReleaseNotes = 'x'.repeat(8000); + + const {default: releaseTaskHelper} = await esmock('../source/release-task-helper.js', import.meta.url, { + open: openStub, + clipboardy: { + write: clipboardStub, + }, + '../source/util.js': { + async getTagVersionPrefix() { + return 'v'; + }, + async getPreReleasePrefix() { + return ''; + }, + }, + 'new-github-release-url'(options_) { + urlCalls.push(options_); + // Generate a realistic URL + const baseUrl = `${options_.repoUrl}/releases/new`; + const parameters = new URLSearchParams({ + tag: options_.tag, + body: options_.body, + prerelease: options_.isPrerelease ? '1' : '0', + }); + return `${baseUrl}?${parameters.toString()}`; + }, + }); + + await releaseTaskHelper( + { + version: '1.1.0', + repoUrl, + releaseNotes: true, + generateReleaseNotes: () => longReleaseNotes, + }, + {version: '1.0.0'}, + ); + + // Should be called twice: once with long notes, once with placeholder + t.is(urlCalls.length, 2); + t.is(urlCalls[0].body, longReleaseNotes); + t.is(urlCalls[1].body, ''); + t.true(clipboardStub.calledOnce); + t.true(clipboardStub.calledWith(longReleaseNotes)); + t.true(openStub.calledOnce); +}); + +test('does not use clipboard when URL is short enough', async t => { + const repoUrl = 'https://github.com/sindresorhus/np'; + const clipboardStub = sinon.stub(); + const openStub = sinon.stub(); + const urlCalls = []; + + const shortReleaseNotes = 'Short release notes'; + + const {default: releaseTaskHelper} = await esmock('../source/release-task-helper.js', import.meta.url, { + open: openStub, + clipboardy: { + write: clipboardStub, + }, + '../source/util.js': { + async getTagVersionPrefix() { + return 'v'; + }, + async getPreReleasePrefix() { + return ''; + }, + }, + 'new-github-release-url'(options_) { + urlCalls.push(options_); + // Generate a realistic URL + const baseUrl = `${options_.repoUrl}/releases/new`; + const parameters = new URLSearchParams({ + tag: options_.tag, + body: options_.body, + prerelease: options_.isPrerelease ? '1' : '0', + }); + return `${baseUrl}?${parameters.toString()}`; + }, + }); + + await releaseTaskHelper( + { + version: '1.1.0', + repoUrl, + releaseNotes: true, + generateReleaseNotes: () => shortReleaseNotes, + }, + {version: '1.0.0'}, + ); + + // Should be called only once with the short notes + t.is(urlCalls.length, 1); + t.is(urlCalls[0].body, shortReleaseNotes); + t.false(clipboardStub.called); + t.true(openStub.calledOnce); +}); From 08a3f645fc3569afec557496f79206b99c967b8c Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 08:09:30 +0700 Subject: [PATCH 133/180] Fix package.json config being ignored with global installation Fixes #587 --- source/config.js | 29 +++++++++----- test/config.js | 101 ++++++++++++++++++++++++----------------------- 2 files changed, 72 insertions(+), 58 deletions(-) diff --git a/source/config.js b/source/config.js index fec3e03b..97b3e44b 100644 --- a/source/config.js +++ b/source/config.js @@ -3,25 +3,36 @@ import isInstalledGlobally from 'is-installed-globally'; import {cosmiconfig} from 'cosmiconfig'; export default async function getConfig(rootDirectory) { - const searchDirectory = isInstalledGlobally ? os.homedir() : rootDirectory; - const searchPlaces = [ '.np-config.json', '.np-config.js', '.np-config.cjs', '.np-config.mjs', + 'package.json', ]; - if (!isInstalledGlobally) { - searchPlaces.push('package.json'); - } - const explorer = cosmiconfig('np', { searchPlaces, - stopDir: searchDirectory, + stopDir: rootDirectory, }); - const {config} = (await explorer.search(searchDirectory)) ?? {}; + // Always read project config + const {config: projectConfig} = (await explorer.search(rootDirectory)) ?? {}; + + // When globally installed, also read global config and merge (project wins) + if (isInstalledGlobally) { + const globalExplorer = cosmiconfig('np', { + searchPlaces: searchPlaces.filter(place => place !== 'package.json'), + stopDir: os.homedir(), + }); + + const {config: globalConfig} = (await globalExplorer.search(os.homedir())) ?? {}; + + return { + ...globalConfig, + ...projectConfig, + }; + } - return config; + return projectConfig; } diff --git a/test/config.js b/test/config.js index b5d80908..155cb384 100644 --- a/test/config.js +++ b/test/config.js @@ -6,87 +6,90 @@ import {readPackage} from '../source/util.js'; const testedModulePath = '../source/config.js'; const getFixture = fixture => path.resolve('test', 'fixtures', 'config', fixture); -const getFixtures = fixtures => fixtures.map(fixture => getFixture(fixture)); -const getConfigsWhenGlobalBinaryIsUsed = async homeDirectory => { - const pathsPackageDirectory = getFixtures(['package-directory', 'local1', 'local2', 'local3']); - - const promises = pathsPackageDirectory.map(async pathPackageDirectory => { - const getConfig = await esmock(testedModulePath, { - 'is-installed-globally': true, - 'node:os': {homedir: () => homeDirectory}, - }); - return getConfig(pathPackageDirectory); +const getConfigWhenGlobalBinaryIsUsed = async pathPackageDirectory => { + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': true, }); - - return Promise.all(promises); + return getConfig(pathPackageDirectory); }; -const getConfigsWhenLocalBinaryIsUsed = async pathPackageDirectory => { - const homeDirectories = getFixtures(['homedir1', 'homedir2', 'homedir3']); - - const promises = homeDirectories.map(async homedir => { - const getConfig = await esmock(testedModulePath, { - 'is-installed-globally': false, - 'node:os': {homedir: () => homedir}, - }); - return getConfig(pathPackageDirectory); +const getConfigWhenLocalBinaryIsUsed = async pathPackageDirectory => { + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': false, }); - - return Promise.all(promises); + return getConfig(pathPackageDirectory); }; -const useGlobalBinary = test.macro(async (t, homeDirectory, source) => { - const configs = await getConfigsWhenGlobalBinaryIsUsed(getFixture(homeDirectory)); - - for (const config of configs) { - t.deepEqual(config, {source}); - } +const useGlobalBinary = test.macro(async (t, packageDirectory, source) => { + const config = await getConfigWhenGlobalBinaryIsUsed(getFixture(packageDirectory)); + t.deepEqual(config, {source}); }); const useLocalBinary = test.macro(async (t, packageDirectory, source) => { - const configs = await getConfigsWhenLocalBinaryIsUsed(getFixture(packageDirectory)); - - for (const config of configs) { - t.deepEqual(config, {source}); - } + const config = await getConfigWhenLocalBinaryIsUsed(getFixture(packageDirectory)); + t.deepEqual(config, {source}); }); test( - 'returns config from home directory when global binary is used and .np-config-json exists in home directory', + 'returns config from package directory when global binary is used and `package.json` exists in package directory', useGlobalBinary, - 'homedir1', - 'homedir/.np-config.json', + 'pkg-dir', + 'package.json', +); + +test( + 'returns config from package directory when global binary is used and `.np-config.json` exists in package directory', + useGlobalBinary, + 'local1', + 'packagedir/.np-config.json', ); test( - 'returns config from home directory when global binary is used and `.np-config.js` as CJS exists in home directory', + 'returns config from package directory when global binary is used and `.np-config.js` as CJS exists in package directory', useGlobalBinary, - 'homedir2', - 'homedir/.np-config.js', + 'local2', + 'packagedir/.np-config.js', ); test( - 'returns config from home directory when global binary is used and `.np-config.cjs` exists in home directory', + 'returns config from package directory when global binary is used and `.np-config.cjs` exists in package directory', useGlobalBinary, - 'homedir3', - 'homedir/.np-config.cjs', + 'local3', + 'packagedir/.np-config.cjs', ); test( - 'returns config from home directory when global binary is used and `.np-config.js` as ESM exists in home directory', + 'returns config from package directory when global binary is used and `.np-config.js` as ESM exists in package directory', useGlobalBinary, - 'homedir4', - 'homedir/.np-config.js', + 'local4', + 'packagedir/.np-config.js', ); test( - 'returns config from home directory when global binary is used and `.np-config.mjs` exists in home directory', + 'returns config from package directory when global binary is used and `.np-config.mjs` exists in package directory', useGlobalBinary, - 'homedir5', - 'homedir/.np-config.mjs', + 'local5', + 'packagedir/.np-config.mjs', ); +test('global binary merges global and project config with project taking precedence', async t => { + const fixtureDirectory = getFixture('pkg-dir'); + + // Create a temporary home directory with global config + const temporaryHome = getFixture('homedir1'); + + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': true, + 'node:os': {homedir: () => temporaryHome}, + }); + + const config = await getConfig(fixtureDirectory); + + // Should have project config + t.is(config.source, 'package.json'); +}); + test( 'returns config from package directory when local binary is used and `package.json` exists in package directory', useLocalBinary, From 26c7f818459f746a7493640b2c55b77e2ecf9d70 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 08:35:17 +0700 Subject: [PATCH 134/180] Add tests for pnpm tag-version-prefix behavior Closes #738 --- test/util/get-tag-version-prefix.js | 42 ++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js index 90bb1e28..de422bc0 100644 --- a/test/util/get-tag-version-prefix.js +++ b/test/util/get-tag-version-prefix.js @@ -1,7 +1,7 @@ import test from 'ava'; import {_createFixture} from '../_helpers/stub-execa.js'; import {getTagVersionPrefix as originalGetTagVersionPrefix} from '../../source/util.js'; -import {npmConfig, yarnConfig} from '../../source/package-manager/configs.js'; +import {npmConfig, yarnConfig, pnpmConfig} from '../../source/package-manager/configs.js'; /** @type {ReturnType>} */ const createFixture = _createFixture('../../source/util.js', import.meta.url); @@ -36,6 +36,46 @@ test('defaults to "v" when command fails', createFixture, [{ ); }); +test('returns tag prefix - pnpm (uses npm config)', createFixture, [{ + command: 'npm config get tag-version-prefix', + stdout: 'v', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(pnpmConfig), + 'v', + ); +}); + +test('returns custom tag prefix - pnpm', createFixture, [{ + command: 'npm config get tag-version-prefix', + stdout: 'ver', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(pnpmConfig), + 'ver', + ); +}); + +test('returns empty string tag prefix - pnpm', createFixture, [{ + command: 'npm config get tag-version-prefix', + stdout: '', +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(pnpmConfig), + '', + ); +}); + +test('pnpm defaults to "v" when npm config fails', createFixture, [{ + command: 'npm config get tag-version-prefix', + exitCode: 1, +}], async ({t, testedModule: {getTagVersionPrefix}}) => { + t.is( + await getTagVersionPrefix(pnpmConfig), + 'v', + ); +}); + test('no options passed', async t => { await t.throwsAsync( originalGetTagVersionPrefix(), From 869f80b97de14412d9e4617984d875cab1afa34d Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 08:45:17 +0700 Subject: [PATCH 135/180] Fix failure with repositories that have multiple initial commits Fixes #452 --- source/git-util.js | 4 +++- test/git-util/multiple-initial-commits.js | 29 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 test/git-util/multiple-initial-commits.js diff --git a/source/git-util.js b/source/git-util.js index 7003bf7b..a5da7c68 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -58,7 +58,9 @@ const tagListSortedBySemver = async () => { const firstCommit = async () => { const {stdout} = await execa('git', ['rev-list', '--max-parents=0', 'HEAD']); - return stdout; + // Repository may have multiple initial commits (e.g., from merging unrelated histories). + // Return just the first one. + return stdout.split('\n')[0]; }; export const previousTagOrFirstCommit = async () => { diff --git a/test/git-util/multiple-initial-commits.js b/test/git-util/multiple-initial-commits.js new file mode 100644 index 00000000..58736eb2 --- /dev/null +++ b/test/git-util/multiple-initial-commits.js @@ -0,0 +1,29 @@ +import test from 'ava'; +import {_createFixture} from '../_helpers/integration-test.js'; + +/** @type {ReturnType>} */ +const createFixture = _createFixture('../../source/git-util.js'); + +test('repository with multiple initial commits', createFixture, async ({t, $$}) => { + // Create a second orphan branch to simulate multiple initial commits + await $$`git checkout --orphan other-branch`; + await t.context.createFile('other-file'); + await $$`git add other-file`; + await $$`git commit -m orphan`; + + // Merge the orphan branch into main, creating multiple root commits + await $$`git checkout main`; + await $$`git merge --allow-unrelated-histories other-branch -m merge`; +}, async ({t, testedModule: {latestTagOrFirstCommit, commitLogFromRevision}}) => { + // This should not throw an error even with multiple initial commits + const result = await latestTagOrFirstCommit(); + + // Verify result is a valid commit hash (single line) + t.false(result.includes('\n'), 'Result should be a single commit hash'); + t.is(result.length, 40, 'Result should be a 40-character SHA-1 hash'); + + // This was the operation that failed in the original issue + await t.notThrowsAsync(async () => { + await commitLogFromRevision(result); + }, 'commitLogFromRevision should work with the returned first commit'); +}); From a0f1f374805860150ca3d602082d8e0b420efa07 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 09:02:57 +0700 Subject: [PATCH 136/180] Clear task output after successful completion Fixes #100 --- source/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/source/index.js b/source/index.js index fbac4c1d..92a959e1 100644 --- a/source/index.js +++ b/source/index.js @@ -265,6 +265,7 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root ], { showSubtasks: false, renderer: options.renderer ?? 'default', + clearOutput: !options.preview && !options.releaseDraftOnly, }); if (!options.runPublish) { From ebb9acf55bc3382b27a79bdd1deb91922f79d866 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 09:12:32 +0700 Subject: [PATCH 137/180] Add git user configuration check to prevent npm version failures Fixes #204 --- source/git-util.js | 17 +++++ source/prerequisite-tasks.js | 4 ++ test/git-util/multiple-initial-commits.js | 7 ++- test/tasks/prerequisite-tasks.js | 77 +++++++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index a5da7c68..ef7d5bcf 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -288,3 +288,20 @@ export const verifyRecentGitVersion = async () => { const installedVersion = await gitVersion(); util.validateEngineVersionSatisfies('git', installedVersion); }; + +export const verifyUserConfigIsSet = async () => { + const [nameResult, emailResult] = await Promise.allSettled([ + execa('git', ['config', 'user.name']), + execa('git', ['config', 'user.email']), + ]); + + if (nameResult.status !== 'fulfilled' || !nameResult.value.stdout || emailResult.status !== 'fulfilled' || !emailResult.value.stdout) { + throw new Error([ + 'Git user configuration is not set.', + '', + 'Please set your git user name and email:', + ' git config --global user.name "Your Name"', + ' git config --global user.email "you@example.com"', + ].join('\n')); + } +}; diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index fe01f6a1..dffe9b4f 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -52,6 +52,10 @@ const prerequisiteTasks = (input, package_, options, packageManager) => { title: 'Check git version', task: async () => git.verifyRecentGitVersion(), }, + { + title: 'Check git user configuration', + task: async () => git.verifyUserConfigIsSet(), + }, { title: 'Check git remote', task: async () => git.verifyRemoteIsValid(), diff --git a/test/git-util/multiple-initial-commits.js b/test/git-util/multiple-initial-commits.js index 58736eb2..2f09b463 100644 --- a/test/git-util/multiple-initial-commits.js +++ b/test/git-util/multiple-initial-commits.js @@ -5,14 +5,17 @@ import {_createFixture} from '../_helpers/integration-test.js'; const createFixture = _createFixture('../../source/git-util.js'); test('repository with multiple initial commits', createFixture, async ({t, $$}) => { + // Get the current branch name before creating orphan branch + const {stdout: initialBranch} = await $$`git branch --show-current`; + // Create a second orphan branch to simulate multiple initial commits await $$`git checkout --orphan other-branch`; await t.context.createFile('other-file'); await $$`git add other-file`; await $$`git commit -m orphan`; - // Merge the orphan branch into main, creating multiple root commits - await $$`git checkout main`; + // Merge the orphan branch into the initial branch, creating multiple root commits + await $$`git checkout ${initialBranch}`; await $$`git merge --allow-unrelated-histories other-branch -m merge`; }, async ({t, testedModule: {latestTagOrFirstCommit, commitLogFromRevision}}) => { // This should not throw an error even with multiple initial commits diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 79beef86..bf9f38c7 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -39,6 +39,12 @@ test.serial('public-package published on npm registry: should fail when npm regi }); test.serial('private package: should disable task pinging npm registry', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { @@ -48,6 +54,12 @@ test.serial('private package: should disable task pinging npm registry', createF }); test.serial('external registry: should disable task pinging npm registry', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { @@ -165,6 +177,12 @@ test.serial('should use publishConfig.registry even when set to official npm reg test.serial.todo('should not fail if no collaborators'); // Verify user is authenticated test.serial('private package: should disable task `verify user is authenticated`', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { @@ -191,6 +209,35 @@ test.serial('should fail when git version does not match range in `package.json` assertTaskFailed(t, 'Check git version'); }); +test.serial('should fail when git user.name is not set', createFixture, [{ + command: 'git config user.name', + exitCode: 1, + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + {message: /Git user configuration is not set/}, + ); + + assertTaskFailed(t, 'Check git user configuration'); +}); + +test.serial('should fail when git user.email is not set', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + exitCode: 1, + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + {message: /Git user configuration is not set/}, + ); + + assertTaskFailed(t, 'Check git user configuration'); +}); + test.serial('should fail when git remote does not exist', createFixture, [{ command: 'git ls-remote origin HEAD', exitCode: 1, @@ -233,6 +280,12 @@ test.serial('should fail when prerelease version of public package without dist }); test.serial('should not fail when prerelease version of public package with dist tag given', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { @@ -240,6 +293,12 @@ test.serial('should not fail when prerelease version of public package with dist }); test.serial('should not fail when prerelease version of private package without dist tag given', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { @@ -247,6 +306,12 @@ test.serial('should not fail when prerelease version of private package without }); test.serial('should fail when git tag already exists', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: 'vvb', }], async ({t, testedModule: prerequisiteTasks}) => { @@ -259,6 +324,12 @@ test.serial('should fail when git tag already exists', createFixture, [{ }); test.serial('checks should pass', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { @@ -266,6 +337,12 @@ test.serial('checks should pass', createFixture, [{ }); test.serial('should skip authentication check when OIDC is detected', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { From 67b6aff26a70546c9d24025e9bd27b3c6e3a2656 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 09:33:42 +0700 Subject: [PATCH 138/180] Fix git commands failing with password-protected SSH keys Fixes #642 --- source/git-util.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/source/git-util.js b/source/git-util.js index ef7d5bcf..325a4cea 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -162,7 +162,8 @@ const hasRemote = async () => { }; const hasUnfetchedChangesFromRemote = async () => { - const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run'], {timeout: gitNetworkTimeout}); + // Inherit stdin to allow SSH password prompts for password-protected keys + const {stdout: possibleNewChanges} = await execa('git', ['fetch', '--dry-run'], {stdin: 'inherit', timeout: gitNetworkTimeout}); // There are unfetched changes if output is not empty. return Boolean(possibleNewChanges); @@ -191,14 +192,16 @@ export const verifyRemoteHistoryIsClean = async () => { export const verifyRemoteIsValid = async () => { try { - await execa('git', ['ls-remote', 'origin', 'HEAD'], {timeout: gitNetworkTimeout}); + // Inherit stdin to allow SSH password prompts for password-protected keys + await execa('git', ['ls-remote', 'origin', 'HEAD'], {stdin: 'inherit', timeout: gitNetworkTimeout}); } catch (error) { throw new Error(error.stderr.replace('fatal:', 'Git fatal error:')); } }; export const fetch = async () => { - await execa('git', ['fetch'], {timeout: gitNetworkTimeout}); + // Inherit stdin to allow SSH password prompts for password-protected keys + await execa('git', ['fetch'], {stdin: 'inherit', timeout: gitNetworkTimeout}); }; const hasLocalBranch = async branch => { @@ -253,7 +256,8 @@ export const commitLogFromRevision = async revision => { }; const push = async (tagArgument = '--follow-tags') => { - await execa('git', ['push', tagArgument], {timeout: gitNetworkTimeout}); + // Inherit stdin to allow SSH password prompts for password-protected keys + await execa('git', ['push', tagArgument], {stdin: 'inherit', timeout: gitNetworkTimeout}); }; export const pushGraceful = async remoteIsOnGitHub => { From a13bba22d819189af02b050a8655cfc31ee8444e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 09:57:29 +0700 Subject: [PATCH 139/180] Fix npm pack JSON parsing when lifecycle scripts output to stdout The --foreground-scripts=false flag doesn't prevent user scripts from writing directly to stdout. For example, Husky's prepare script can output text that pollutes the JSON output from npm pack --json, causing parsing failures. --- source/npm/util.js | 6 +++++- test/fixtures/files/prepare-script/index.js | 3 +++ test/fixtures/files/prepare-script/package.json | 8 ++++++++ test/npm/util/packed-files.js | 3 +++ 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/files/prepare-script/index.js create mode 100644 test/fixtures/files/prepare-script/package.json diff --git a/source/npm/util.js b/source/npm/util.js index 5f1886af..30e4b418 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -192,7 +192,11 @@ export const getFilesToBePacked = async rootDirectory => { ], {cwd: rootDirectory}); try { - const {files} = JSON.parse(stdout).at(0); + // HACK: NPM lifecycle scripts can output text even with --silent and --foreground-scripts=false. + // For example, Husky's prepare script outputs "> package@version prepare" and "> husky install". + // We extract only the JSON portion by finding the first '[' character. + // Related: https://github.com/sindresorhus/np/issues/742 + const {files} = JSON.parse(stdout.slice(Math.max(0, stdout.indexOf('[')))).at(0); return files.map(file => file.path); } catch (error) { throw new Error('Failed to parse output of npm pack', {cause: error}); diff --git a/test/fixtures/files/prepare-script/index.js b/test/fixtures/files/prepare-script/index.js new file mode 100644 index 00000000..50862df2 --- /dev/null +++ b/test/fixtures/files/prepare-script/index.js @@ -0,0 +1,3 @@ +export default function foo() { + return 'bar'; +} diff --git a/test/fixtures/files/prepare-script/package.json b/test/fixtures/files/prepare-script/package.json new file mode 100644 index 00000000..5a121c9b --- /dev/null +++ b/test/fixtures/files/prepare-script/package.json @@ -0,0 +1,8 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"], + "scripts": { + "prepare": "echo '> foo@0.0.0 prepare' && echo '> test prepare script'" + } +} diff --git a/test/npm/util/packed-files.js b/test/npm/util/packed-files.js index da2d1f6a..8ef1e72c 100644 --- a/test/npm/util/packed-files.js +++ b/test/npm/util/packed-files.js @@ -71,3 +71,6 @@ test('doesn\'t show files in .github', verifyPackedFiles, 'dot-github', [ 'index.js', ]); +test('handles prepare script output (e.g., Husky)', verifyPackedFiles, 'prepare-script', [ + 'index.js', +]); From 753abb4c430dd56038da88f7bf5e80a7f57880d0 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 10:13:14 +0700 Subject: [PATCH 140/180] Fix `publishConfig` handling for access and registry fields Fixes #751 --- source/npm/util.js | 10 +++++++++- source/ui.js | 7 ++----- test/npm/util/is-external-registry.js | 8 ++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index 30e4b418..99139620 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -84,7 +84,15 @@ const NPM_DEFAULT_REGISTRIES = new Set([ ]); export const isExternalRegistry = package_ => { const registry = package_.publishConfig?.registry; - return typeof registry === 'string' && !NPM_DEFAULT_REGISTRIES.has(registry); + if (typeof registry !== 'string') { + return false; + } + + const normalizedRegistry = registry.trim(); + const httpsVariant = normalizedRegistry.replace(/^http:\/\//, 'https://'); + + return !NPM_DEFAULT_REGISTRIES.has(normalizedRegistry) + && !NPM_DEFAULT_REGISTRIES.has(httpsVariant); }; export const collaborators = async package_ => { diff --git a/source/ui.js b/source/ui.js index 438c3abf..4e8d486d 100644 --- a/source/ui.js +++ b/source/ui.js @@ -290,11 +290,8 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { return false; } - if (!package_.publishConfig) { - return true; - } - - return package_.publishConfig.access !== 'restricted' && !npm.isExternalRegistry(package_); + // Only ask if access is not explicitly set and not using an external registry + return !package_.publishConfig?.access && !npm.isExternalRegistry(package_); })(); const answers = await inquirer.prompt({ diff --git a/test/npm/util/is-external-registry.js b/test/npm/util/is-external-registry.js index 6b7bcadb..f24af402 100644 --- a/test/npm/util/is-external-registry.js +++ b/test/npm/util/is-external-registry.js @@ -9,4 +9,12 @@ test('main', t => { t.false(npm.isExternalRegistry({publishConfig: 'not an object'})); t.false(npm.isExternalRegistry({publishConfig: {registry: 'https://registry.npmjs.org'}})); t.false(npm.isExternalRegistry({publishConfig: {registry: 'https://registry.npmjs.org/'}})); + + // Test normalization: whitespace trimming + t.false(npm.isExternalRegistry({publishConfig: {registry: ' https://registry.npmjs.org '}})); + t.false(npm.isExternalRegistry({publishConfig: {registry: ' https://registry.npmjs.org/ '}})); + + // Test normalization: http variant + t.false(npm.isExternalRegistry({publishConfig: {registry: 'http://registry.npmjs.org'}})); + t.false(npm.isExternalRegistry({publishConfig: {registry: 'http://registry.npmjs.org/'}})); }); From 3804d0b6abb8ac0fa3855ad2e11b13e49811306d Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 10:32:02 +0700 Subject: [PATCH 141/180] Fix CLI default flags overriding local config values Fixes #754 --- source/cli-implementation.js | 20 ++++++++++++------- test/config.js | 15 ++++++++++++++ .../config/flag-precedence/.np-config.json | 5 +++++ .../config/flag-precedence/package.json | 4 ++++ 4 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/config/flag-precedence/.np-config.json create mode 100644 test/fixtures/config/flag-precedence/package.json diff --git a/source/cli-implementation.js b/source/cli-implementation.js index a6ac5436..73bc1c6e 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -50,6 +50,7 @@ const cli = meow(` `, { importMeta: import.meta, booleanDefault: undefined, + // Don't use `default` for flags - we apply defaults later so config can override them flags: { anyBranch: { type: 'boolean', @@ -59,29 +60,24 @@ const cli = meow(` }, cleanup: { type: 'boolean', - default: true, }, tests: { type: 'boolean', - default: true, }, yolo: { type: 'boolean', }, publish: { type: 'boolean', - default: true, }, releaseDraft: { type: 'boolean', - default: true, }, releaseDraftOnly: { type: 'boolean', }, releaseNotes: { type: 'boolean', - default: true, }, tag: { type: 'string', @@ -100,7 +96,6 @@ const cli = meow(` }, '2fa': { type: 'boolean', - default: true, }, message: { type: 'string', @@ -120,9 +115,20 @@ async function getOptions() { const {package_, rootDirectory} = await util.readPackage(contents); const localConfig = await config(rootDirectory); + + // Filter out undefined CLI flags (not provided by user) + const explicitCliFlags = Object.fromEntries(Object.entries(cli.flags).filter(([, value]) => value !== undefined)); + + // Merge: local config β†’ explicit CLI flags β†’ defaults const flags = { + cleanup: true, + tests: true, + publish: true, + releaseDraft: true, + releaseNotes: true, + '2fa': true, ...localConfig, - ...cli.flags, + ...explicitCliFlags, }; // Workaround for unintended auto-casing behavior from `meow`. diff --git a/test/config.js b/test/config.js index 155cb384..01ee1502 100644 --- a/test/config.js +++ b/test/config.js @@ -152,3 +152,18 @@ test('`contents` option in config allows reading package from subdirectory', asy t.is(package_.name, 'from-dist'); t.is(rootDirectory, contentsPath); }); + +test('config values override defaults', async t => { + const fixtureDirectory = getFixture('flag-precedence'); + + const getConfig = await esmock(testedModulePath, { + 'is-installed-globally': false, + }); + + const config = await getConfig(fixtureDirectory); + + // Config should override default values + t.is(config.tests, false); + t.is(config.cleanup, false); + t.is(config.publish, false); +}); diff --git a/test/fixtures/config/flag-precedence/.np-config.json b/test/fixtures/config/flag-precedence/.np-config.json new file mode 100644 index 00000000..87eeb50e --- /dev/null +++ b/test/fixtures/config/flag-precedence/.np-config.json @@ -0,0 +1,5 @@ +{ + "tests": false, + "cleanup": false, + "publish": false +} diff --git a/test/fixtures/config/flag-precedence/package.json b/test/fixtures/config/flag-precedence/package.json new file mode 100644 index 00000000..f379bf99 --- /dev/null +++ b/test/fixtures/config/flag-precedence/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-flag-precedence", + "version": "1.0.0" +} From e996c6f5c5508305b8869ed53f03b3c9250775b6 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 11:38:34 +0700 Subject: [PATCH 142/180] Fix subfolder publishing broken since npm 8.5 npm 8.5+ has a bug where `npm publish ` publishes from the current working directory instead of from the specified folder. This causes the published package to incorrectly include files from both the root directory and the subfolder. Fixes #656 --- source/index.js | 4 ++-- source/npm/publish.js | 23 +++++++++++------------ test/npm/publish.js | 35 ++++++----------------------------- 3 files changed, 19 insertions(+), 43 deletions(-) diff --git a/source/index.js b/source/index.js index 92a959e1..83197650 100644 --- a/source/index.js +++ b/source/index.js @@ -196,11 +196,11 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root task(context, task) { let hasError = false; - return from(runPublish(getPublishCommand(options))) + return from(runPublish(getPublishCommand(options), {cwd: options.rootDirectory})) .pipe(catchError(error => handleNpmError(error, task, otp => { context.otp = otp; - return runPublish(getPublishCommand({...options, otp})); + return runPublish(getPublishCommand({...options, otp}), {cwd: options.rootDirectory}); }))) .pipe( // Note: Cannot use `async` here as the `await` will not finish before the error propagates. diff --git a/source/npm/publish.js b/source/npm/publish.js index 2cb61a21..03151a18 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -1,18 +1,8 @@ -import path from 'node:path'; import {execa} from 'execa'; export const getPackagePublishArguments = options => { const arguments_ = ['publish']; - if (options.contents) { - // Normalize to explicit relative path so npm doesn't interpret it as a package name - const contents = path.isAbsolute(options.contents) || options.contents.startsWith('.') - ? options.contents - : `./${options.contents}`; - - arguments_.push(contents); - } - if (options.tag) { arguments_.push('--tag', options.tag); } @@ -28,8 +18,17 @@ export const getPackagePublishArguments = options => { return arguments_; }; -export function runPublish(arguments_) { - const cp = execa(...arguments_); +export function runPublish(arguments_, options = {}) { + const execaOptions = {}; + + // `npm` 8.5+ has a bug where `npm publish ` publishes from cwd instead of . + // We work around this by changing cwd to the target directory. + // https://github.com/npm/cli/issues/5136 + if (options.cwd) { + execaOptions.cwd = options.cwd; + } + + const cp = execa(...arguments_, execaOptions); cp.stdout.on('data', chunk => { // https://github.com/yarnpkg/berry/blob/a3e5695186f2aec3a68810acafc6c9b1e45191da/packages/plugin-npm/sources/npmHttpUtils.ts#L541 diff --git a/test/npm/publish.js b/test/npm/publish.js index 376e60a2..2f02e6d8 100644 --- a/test/npm/publish.js +++ b/test/npm/publish.js @@ -1,5 +1,5 @@ import test from 'ava'; -import {getPackagePublishArguments} from '../../source/npm/publish.js'; +import {getPackagePublishArguments, runPublish} from '../../source/npm/publish.js'; test('no options set', t => { t.deepEqual( @@ -8,34 +8,6 @@ test('no options set', t => { ); }); -test('options.contents - normalizes relative path', t => { - t.deepEqual( - getPackagePublishArguments({contents: 'dist'}), - ['publish', './dist'], - ); -}); - -test('options.contents - preserves ./ prefix', t => { - t.deepEqual( - getPackagePublishArguments({contents: './dist'}), - ['publish', './dist'], - ); -}); - -test('options.contents - preserves ../ prefix', t => { - t.deepEqual( - getPackagePublishArguments({contents: '../dist'}), - ['publish', '../dist'], - ); -}); - -test('options.contents - preserves absolute path', t => { - t.deepEqual( - getPackagePublishArguments({contents: '/absolute/path'}), - ['publish', '/absolute/path'], - ); -}); - test('options.tag', t => { t.deepEqual( getPackagePublishArguments({tag: 'beta'}), @@ -56,3 +28,8 @@ test('options.publishScoped', t => { ['publish', '--access', 'public'], ); }); + +test('runPublish uses cwd option when provided', async t => { + const result = await runPublish(['echo', ['test']], {cwd: '/tmp'}); + t.is(result.cwd, '/tmp'); +}); From 381118279847df198881297344654711ba211572 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 11:52:17 +0700 Subject: [PATCH 143/180] Fix first publish failure for prerelease versions with npm 10+ Fixes #673 --- source/npm/util.js | 15 +++++++++++---- test/npm/util/prerelease-tags.js | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/source/npm/util.js b/source/npm/util.js index 99139620..c13b51f8 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -130,17 +130,24 @@ export const prereleaseTags = async packageName => { } catch (error) { throwIfNpmTimeout(error); // HACK: NPM is mixing JSON with plain text errors. Luckily, the error - // always starts with 'npm ERR!' (unless you have a debugger attached) + // always starts with 'npm ERR!' (npm <10) or 'npm error' (npm >=10) // so as a solution, until npm/cli#2740 is fixed, we can remove anything - // starting with 'npm ERR!' + // starting with 'npm ERR!' or 'npm error' /** @type {string} */ const errorMessage = error.stderr; const errorJSON = errorMessage .split('\n') - .filter(error => !error.startsWith('npm ERR!')) + .filter(line => !line.startsWith('npm ERR!') && !line.startsWith('npm error')) .join('\n'); - if (((JSON.parse(errorJSON) || {}).error || {}).code !== 'E404') { + try { + const parsed = JSON.parse(errorJSON); + // Only handle E404 errors gracefully; throw all other errors + if (parsed?.error?.code !== 'E404') { + throw error; + } + } catch { + // If JSON parsing fails, we can't determine the error type, so throw the original error throw error; } } diff --git a/test/npm/util/prerelease-tags.js b/test/npm/util/prerelease-tags.js index 6424e414..7a9a120e 100644 --- a/test/npm/util/prerelease-tags.js +++ b/test/npm/util/prerelease-tags.js @@ -65,6 +65,32 @@ test('non-existent (code 404) - should not throw', createFixture, [{ ); }); +test('non-existent with modern npm format (npm >=10) - should not throw', createFixture, [{ + command: 'npm view --json non-existent dist-tags', + stderr: stripIndent` + npm error code E404 + npm error 404 Not Found - GET https://registry.npmjs.org/non-existent - Not found + npm error 404 + npm error 404 The requested resource 'non-existent@*' could not be found or you do not have permission to access it. + npm error 404 + npm error 404 Note that you can also install from a + npm error 404 tarball, folder, http url, or git url. + { + "error": { + "code": "E404", + "summary": "Not Found - GET https://registry.npmjs.org/non-existent - Not found", + "detail": "The requested resource 'non-existent@*' could not be found." + } + } + npm error A complete log of this run can be found in: ~/.npm/_logs/...-debug.log + `, +}], async ({t, testedModule: {prereleaseTags}}) => { + t.deepEqual( + await prereleaseTags('non-existent'), + ['next'], + ); +}); + test('bad permission (code 403) - should throw', createFixture, [{ command: 'npm view --json @private/pkg dist-tags', stderr: stripIndent` From a651fc4c46d632ad1a341428071e2669e4a68e67 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 12:08:21 +0700 Subject: [PATCH 144/180] Fix publish hanging indefinitely Adds stdin inheritance and timeout to prevent publish from hanging when: - npm/yarn prompts for authentication or OTP - Lifecycle scripts run in watch mode - Other unexpected blocking conditions occur The timeout (3 minutes) matches git network operations timeout. Fixes #312 --- readme.md | 53 +++++++++++++++++++++++++++++++++++++++++++ source/npm/publish.js | 22 +++++++++--------- test/npm/publish.js | 7 ++++++ 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/readme.md b/readme.md index 97df89e8..2a0427c7 100644 --- a/readme.md +++ b/readme.md @@ -337,6 +337,59 @@ npm ERR! 403 Forbidden - GET https://registry.yarnpkg.com/-/package/my-awesome-p } ``` +### np hangs during the "Publishing package" step + +If `np` hangs indefinitely during publishing, common causes include: + +**Lifecycle scripts that don't exit** + +npm automatically runs lifecycle hooks like `prepublish`, `publish`, and `postpublish` during publishing. If these scripts don't exit (e.g., running in watch mode), `np` will hang. + +```json +{ + "scripts": { + "test": "vitest", + "publish": "npm run test && np" + } +} +``` + +**Solution**: Don't name your scripts `publish`, `prepublish`, or `postpublish` (these are reserved npm lifecycle hooks). Use names like `release` instead: + +```json +{ + "scripts": { + "test": "vitest run", + "release": "np" + } +} +``` + +**Tests running in watch mode** + +If your test script runs in watch mode, it won't exit after running tests. + +**Solution**: Ensure your test command exits after running: + +```json +{ + "scripts": { + "test": "vitest run", + "test:dev": "vitest" + } +} +``` + +**Registry configuration issues** + +A missing trailing slash in `.npmrc` registry configuration can cause hangs. + +**Solution**: Ensure registry URLs have a trailing slash: + +```npmrc +@ORG:registry=https://npm.pkg.github.com/ +``` + ## Maintainers - [Sindre Sorhus](https://github.com/sindresorhus) diff --git a/source/npm/publish.js b/source/npm/publish.js index 03151a18..fa32fe99 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -18,8 +18,17 @@ export const getPackagePublishArguments = options => { return arguments_; }; +// 3 minutes timeout for publish operations (like git network operations) +// Publishing can take time for large packages or slow connections +const publishTimeout = 180_000; + export function runPublish(arguments_, options = {}) { - const execaOptions = {}; + const execaOptions = { + // Inherit stdin to allow password/OTP prompts from npm/yarn + stdin: 'inherit', + // Timeout to prevent infinite hangs (e.g., from lifecycle scripts in watch mode) + timeout: publishTimeout, + }; // `npm` 8.5+ has a bug where `npm publish ` publishes from cwd instead of . // We work around this by changing cwd to the target directory. @@ -28,14 +37,5 @@ export function runPublish(arguments_, options = {}) { execaOptions.cwd = options.cwd; } - const cp = execa(...arguments_, execaOptions); - - cp.stdout.on('data', chunk => { - // https://github.com/yarnpkg/berry/blob/a3e5695186f2aec3a68810acafc6c9b1e45191da/packages/plugin-npm/sources/npmHttpUtils.ts#L541 - if (chunk.toString('utf8').includes('One-time password:')) { - cp.kill(); - } - }); - - return cp; + return execa(...arguments_, execaOptions); } diff --git a/test/npm/publish.js b/test/npm/publish.js index 2f02e6d8..70c0b8e3 100644 --- a/test/npm/publish.js +++ b/test/npm/publish.js @@ -33,3 +33,10 @@ test('runPublish uses cwd option when provided', async t => { const result = await runPublish(['echo', ['test']], {cwd: '/tmp'}); t.is(result.cwd, '/tmp'); }); + +test('runPublish sets stdin to inherit and includes timeout', async t => { + const result = runPublish(['echo', ['test']]); + t.not(result, undefined); + // Process should complete successfully with our default options + await t.notThrowsAsync(result); +}); From ebdcbaa6aaf32b8901e80a340647a7f361254072 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 13:14:11 +0700 Subject: [PATCH 145/180] Fix rejection of explicit version numbers Fixes #748 --- source/ui.js | 2 +- test/version.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/source/ui.js b/source/ui.js index 4e8d486d..6d246e8c 100644 --- a/source/ui.js +++ b/source/ui.js @@ -147,7 +147,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { 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}, next: ${new Version(oldVersion).setFrom(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`); diff --git a/test/version.js b/test/version.js index 30e203f5..afeecc8d 100644 --- a/test/version.js +++ b/test/version.js @@ -232,3 +232,12 @@ test('optionally set prereleasePrefix', t => { t.is(new Version('1.0.0', 'prerelease', {prereleasePrefix: 'alpha'}).toString(), '1.0.1-alpha.0'); t.is(new Version('1.0.0').setFrom('prerelease', {prereleasePrefix: 'beta'}).toString(), '1.0.1-beta.0'); }); + +test('setFrom with explicit version and format - for UI display', t => { + // This pattern is used in ui.js to display version info when user provides explicit version + const currentVersion = '1.0.0'; + const explicitVersion = '2.5.0'; + const formattedVersion = new Version(currentVersion).setFrom(explicitVersion).format(); + + t.is(formattedVersion, makeNewFormattedVersion('{2}.5.0')); +}); From 0a8af7b0d3dbf26466c974d54f146c5b07f39041 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 14:14:50 +0700 Subject: [PATCH 146/180] Fix ENOWORKSPACES error when publishing from monorepo workspace npm config commands don't support workspace operations. When running from within a workspace directory, npm would fail with ENOWORKSPACES. Adding --workspaces=false flag to all npm config commands fixes this issue. Fixes #715 --- source/package-manager/configs.js | 10 +++++----- test/util/get-tag-version-prefix.js | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/source/package-manager/configs.js b/source/package-manager/configs.js index 2e59f444..e5d82608 100644 --- a/source/package-manager/configs.js +++ b/source/package-manager/configs.js @@ -5,8 +5,8 @@ export const npmConfig = { installCommand: ['npm', ['ci', '--engine-strict']], installCommandNoLockfile: ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict']], versionCommand: version => ['npm', ['version', version]], - getRegistryCommand: ['npm', ['config', 'get', 'registry']], - tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix']], + getRegistryCommand: ['npm', ['config', 'get', 'registry', '--workspaces=false']], + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix', '--workspaces=false']], lockfiles: ['package-lock.json', 'npm-shrinkwrap.json'], }; @@ -18,7 +18,7 @@ export const pnpmConfig = { installCommandNoLockfile: ['pnpm', ['install']], versionCommand: version => ['pnpm', ['version', version]], // By default, pnpm config returns `undefined` instead of `v` for tag-version-prefix, so for consistent default behavior, use npm. - tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix']], + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix', '--workspaces=false']], // Disable duplicated pnpm Git checks publishCommand: arguments_ => ['pnpm', [...arguments_, '--no-git-checks']], getRegistryCommand: ['pnpm', ['config', 'get', 'registry']], @@ -63,7 +63,7 @@ export const bunConfig = { // Bun doesn't support publishing, so we use npm instead. See https://github.com/oven-sh/bun/issues/5050 publishCommand: arguments_ => ['npm', arguments_], // TODO: Bun doesn't support config get registry, this should be added in the future. See https://github.com/oven-sh/bun/issues/7140 - getRegistryCommand: ['npm', ['config', 'get', 'registry']], - tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix']], + getRegistryCommand: ['npm', ['config', 'get', 'registry', '--workspaces=false']], + tagVersionPrefixCommand: ['npm', ['config', 'get', 'tag-version-prefix', '--workspaces=false']], lockfiles: ['bun.lockb', 'bun.lock'], }; diff --git a/test/util/get-tag-version-prefix.js b/test/util/get-tag-version-prefix.js index de422bc0..a6ab1502 100644 --- a/test/util/get-tag-version-prefix.js +++ b/test/util/get-tag-version-prefix.js @@ -7,7 +7,7 @@ import {npmConfig, yarnConfig, pnpmConfig} from '../../source/package-manager/co const createFixture = _createFixture('../../source/util.js', import.meta.url); test('returns tag prefix - npm', createFixture, [{ - command: 'npm config get tag-version-prefix', + command: 'npm config get tag-version-prefix --workspaces=false', stdout: 'ver', }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( @@ -27,7 +27,7 @@ test('returns preId postfix - yarn', createFixture, [{ }); test('defaults to "v" when command fails', createFixture, [{ - command: 'npm config get tag-version-prefix', + command: 'npm config get tag-version-prefix --workspaces=false', exitCode: 1, }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( @@ -37,7 +37,7 @@ test('defaults to "v" when command fails', createFixture, [{ }); test('returns tag prefix - pnpm (uses npm config)', createFixture, [{ - command: 'npm config get tag-version-prefix', + command: 'npm config get tag-version-prefix --workspaces=false', stdout: 'v', }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( @@ -47,7 +47,7 @@ test('returns tag prefix - pnpm (uses npm config)', createFixture, [{ }); test('returns custom tag prefix - pnpm', createFixture, [{ - command: 'npm config get tag-version-prefix', + command: 'npm config get tag-version-prefix --workspaces=false', stdout: 'ver', }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( @@ -57,7 +57,7 @@ test('returns custom tag prefix - pnpm', createFixture, [{ }); test('returns empty string tag prefix - pnpm', createFixture, [{ - command: 'npm config get tag-version-prefix', + command: 'npm config get tag-version-prefix --workspaces=false', stdout: '', }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( @@ -67,7 +67,7 @@ test('returns empty string tag prefix - pnpm', createFixture, [{ }); test('pnpm defaults to "v" when npm config fails', createFixture, [{ - command: 'npm config get tag-version-prefix', + command: 'npm config get tag-version-prefix --workspaces=false', exitCode: 1, }], async ({t, testedModule: {getTagVersionPrefix}}) => { t.is( From d8f3322b6a4af36aafc3f06c21ca8c87fa357589 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 14:29:57 +0700 Subject: [PATCH 147/180] Add npm provenance support Fixes #701 --- readme.md | 2 ++ source/cli-implementation.js | 4 ++++ source/npm/publish.js | 4 ++++ test/cli.js | 1 + test/npm/publish.js | 7 +++++++ 5 files changed, 18 insertions(+) diff --git a/readme.md b/readme.md index 2a0427c7..5ccb3ad8 100644 --- a/readme.md +++ b/readme.md @@ -73,6 +73,7 @@ $ np --help --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) --package-manager Use a specific package manager (default: 'packageManager' field in package.json) + --provenance Publish with npm provenance statements (CI-only) Examples $ np @@ -109,6 +110,7 @@ Currently, these are the flags you can configure: - `2fa` - Enable 2FA on new packages (`true` by default) (setting this to `false` is not recommended). - `message` - The commit message used for the version bump. Any `%s` in the string will be replaced with the new version. By default, npm uses `%s` and Yarn uses `v%s`. - `packageManager` - Set the package manager to be used. Defaults to the [packageManager field in package.json](https://nodejs.org/dist/latest-v16.x/docs/api/all.html#all_packages_packagemanager), so only use if you can't update package.json for some reason. +- `provenance` - Publish with [npm provenance statements](https://docs.npmjs.com/generating-provenance-statements) (`false` by default). Requires npm 9.5.0+ and a supported CI environment (GitHub Actions or GitLab CI/CD). For example, this configures `np` to use `unit-test` as a test script, and to use `dist` as the subdirectory to publish: diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 73bc1c6e..f2f0972b 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -40,6 +40,7 @@ const cli = meow(` --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) --package-manager Use a specific package manager (default: 'packageManager' field in package.json) + --provenance Publish with npm provenance statements (CI-only) Examples $ np @@ -100,6 +101,9 @@ const cli = meow(` message: { type: 'string', }, + provenance: { + type: 'boolean', + }, }, }); diff --git a/source/npm/publish.js b/source/npm/publish.js index fa32fe99..1bade19f 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -15,6 +15,10 @@ export const getPackagePublishArguments = options => { arguments_.push('--access', 'public'); } + if (options.provenance) { + arguments_.push('--provenance'); + } + return arguments_; }; diff --git a/test/cli.js b/test/cli.js index b2c8aa12..b04c4608 100644 --- a/test/cli.js +++ b/test/cli.js @@ -32,6 +32,7 @@ test('flags: --help', cliPasses, cli, '--help', [ '--no-2fa Don\'t enable 2FA on new packages (not recommended)', '--message Version bump commit message, \'%s\' will be replaced with version (default: \'%s\' with npm and \'v%s\' with yarn)', '--package-manager Use a specific package manager (default: \'packageManager\' field in package.json)', + '--provenance Publish with npm provenance statements (CI-only)', '', 'Examples', '$ np', diff --git a/test/npm/publish.js b/test/npm/publish.js index 70c0b8e3..0a1b3d7c 100644 --- a/test/npm/publish.js +++ b/test/npm/publish.js @@ -29,6 +29,13 @@ test('options.publishScoped', t => { ); }); +test('options.provenance', t => { + t.deepEqual( + getPackagePublishArguments({provenance: true}), + ['publish', '--provenance'], + ); +}); + test('runPublish uses cwd option when provided', async t => { const result = await runPublish(['echo', ['test']], {cwd: '/tmp'}); t.is(result.cwd, '/tmp'); From efcc05e47e29d84f5e8b4d82bbf99070a0900c85 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 15:32:23 +0700 Subject: [PATCH 148/180] Show unpublished files as warning instead of blocking prompt Fixes #713 --- source/ui.js | 52 ++++++++++++++++++++++--------- test/ui/new-files-dependencies.js | 2 +- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/source/ui.js b/source/ui.js index 6d246e8c..17d3cd8e 100644 --- a/source/ui.js +++ b/source/ui.js @@ -81,21 +81,18 @@ 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; + // Only prompt for first-time files and new dependencies (things that WILL be published) + if (noNewFirstTimeFiles && noNewDependencies) { + return { + confirmed: true, + unpublishedFiles: newFiles.unpublished || [], + }; } 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.`); } @@ -106,7 +103,10 @@ const checkNewFilesAndDependencies = async (package_, rootDirectory) => { if (!isInteractive()) { console.log(messages.join('\n')); - return true; + return { + confirmed: true, + unpublishedFiles: newFiles.unpublished || [], + }; } const answers = await inquirer.prompt([{ @@ -116,7 +116,26 @@ const checkNewFilesAndDependencies = async (package_, rootDirectory) => { default: false, }]); - return answers.confirm; + return { + confirmed: answers.confirm, + unpublishedFiles: newFiles.unpublished || [], + }; +}; + +const displayUnpublishedFilesWarning = unpublishedFiles => { + if (!unpublishedFiles || unpublishedFiles.length === 0) { + return; + } + + console.log([ + '', + chalk.yellow('⚠ WARNING: The following new files will NOT be published:'), + chalk.dim(util.groupFilesInFolders(unpublishedFiles)), + '', + chalk.yellow('These files are excluded by your package.json "files" field.'), + chalk.yellow('If you intended to publish them, add them to the "files" field.'), + '', + ].join('\n')); }; /** @@ -131,14 +150,16 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { const {stdout: registryUrl} = await execa(...packageManager.getRegistryCommand); const releaseBranch = options.branch; + let unpublishedFiles; if (options.runPublish) { await npm.checkIgnoreStrategy(package_, rootDirectory); - const answerIgnoredFiles = await checkNewFilesAndDependencies(package_, rootDirectory); - if (!answerIgnoredFiles) { + const {confirmed, unpublishedFiles: files} = await checkNewFilesAndDependencies(package_, rootDirectory); + unpublishedFiles = files; + if (!confirmed) { return { ...options, - confirm: answerIgnoredFiles, + confirm: confirmed, }; } } @@ -156,6 +177,9 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { const useLatestTag = !options.releaseDraftOnly; const {hasCommits, hasUnreleasedCommits, generateReleaseNotes} = await printCommitLog(repoUrl, registryUrl, useLatestTag, releaseBranch); + // Display unpublished files warning after commit log + displayUnpublishedFilesWarning(unpublishedFiles); + if (hasUnreleasedCommits && options.releaseDraftOnly) { const answers = await inquirer.prompt({ confirm: { diff --git a/test/ui/new-files-dependencies.js b/test/ui/new-files-dependencies.js index 9bd31f6b..d7736968 100644 --- a/test/ui/new-files-dependencies.js +++ b/test/ui/new-files-dependencies.js @@ -19,7 +19,7 @@ const checkLines = message => ( } ); -const checkNewUnpublished = checkLines('The following new files will not be part of your published package:'); +const checkNewUnpublished = checkLines('⚠ WARNING: The following new files will NOT be published:'); const checkFirstTimeFiles = checkLines('The following new files will be published for the first time:'); const checkNewDependencies = checkLines('The following new dependencies will be part of your published package:'); From 5a96f8023a89c94da79e752397999bdf70a5ef5c Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 15:58:48 +0700 Subject: [PATCH 149/180] Support GPG password prompts during version bumping Fixes #79 --- source/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/index.js b/source/index.js index 83197650..86a13c8a 100644 --- a/source/index.js +++ b/source/index.js @@ -179,7 +179,8 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root arguments_.push('--message', options.message); } - return exec(cli, arguments_); + // Inherit stdin to allow GPG password prompts for commit signing + return exec(cli, arguments_, {stdin: 'inherit'}); }, }, ...options.runPublish From 6b51dda836664d960fc7dd00f0d5160957038e7f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 16:12:52 +0700 Subject: [PATCH 150/180] Add interactive prerelease identifier selection Fixes #643 --- source/ui.js | 66 ++++++++++++++++++++++++++++++++------ test/ui/prompts/tags.js | 5 +++ test/ui/prompts/version.js | 66 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/source/ui.js b/source/ui.js index 17d3cd8e..228c01ed 100644 --- a/source/ui.js +++ b/source/ui.js @@ -5,11 +5,19 @@ import {htmlEscape} from 'escape-goat'; import isScoped from 'is-scoped'; import isInteractive from 'is-interactive'; import {execa} from 'execa'; +import semver from 'semver'; 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 PRERELEASE_INCREMENTS = new Set([ + 'prepatch', + 'preminor', + 'premajor', + 'prerelease', +]); + const printCommitLog = async (repoUrl, registryUrl, fromLatestTag, releaseBranch) => { const revision = fromLatestTag ? await git.latestTagOrFirstCommit() : await git.previousTagOrFirstCommit(); if (!revision) { @@ -297,11 +305,19 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { } } - const needsPrereleaseTag = answers => ( - options.runPublish - && (answers.version?.isPrerelease() || answers.customVersion?.isPrerelease()) - && !options.tag - ); + const needsPrereleaseTag = answers => { + if (!options.runPublish || options.tag) { + return false; + } + + // Check if version is a prerelease increment + if (answers.version) { + return PRERELEASE_INCREMENTS.has(answers.version); + } + + // Check if custom version is a prerelease + return answers.customVersion?.isPrerelease(); + }; const alreadyPublicScoped = packageManager.id === 'yarn-berry' && options.runPublish && await util.getNpmPackageAccess(package_) === 'public'; @@ -318,6 +334,13 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { return !package_.publishConfig?.access && !npm.isExternalRegistry(package_); })(); + // Extract prerelease identifier from current version if it exists, otherwise use npm config + const currentPrerelease = semver.prerelease(oldVersion); + // Only use the prefix if it's a string (not a number like in '1.0.0-0') + const currentPrereleasePrefix = typeof currentPrerelease?.[0] === 'string' ? currentPrerelease[0] : undefined; + const configPrereleasePrefix = await util.getPreReleasePrefix(packageManager); + const defaultPrereleasePrefix = currentPrereleasePrefix ?? configPrereleasePrefix; + const answers = await inquirer.prompt({ version: { type: 'select', @@ -325,8 +348,8 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { pageSize: SEMVER_INCREMENTS.length + 2, default: 0, choices: [ - ...SEMVER_INCREMENTS.map(increment => ({ // TODO: prerelease prefix here too - name: `${increment} ${new Version(oldVersion, increment).format()}`, + ...SEMVER_INCREMENTS.map(increment => ({ + name: `${increment} ${new Version(oldVersion, increment, {prereleasePrefix: defaultPrereleasePrefix}).format()}`, value: increment, })), new inquirer.Separator(), @@ -335,7 +358,6 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { value: undefined, }, ], - filter: input => input ? new Version(oldVersion, input) : input, }, customVersion: { type: 'input', @@ -364,6 +386,20 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { return version; }, }, + prereleasePrefix: { + type: 'input', + message: 'Prerelease identifier', + // Use || not ?? to treat empty string as falsy (show 'rc' instead of empty default) + default: defaultPrereleasePrefix || 'rc', + when(answers) { + // Only ask when a prerelease increment was selected from the menu + if (!answers.version) { + return false; + } + + return PRERELEASE_INCREMENTS.has(answers.version); + }, + }, tag: { type: 'select', message: 'How should this pre-release version be tagged in npm?', @@ -405,9 +441,21 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { }, }); + // Create Version object with custom prerelease prefix if provided + let version; + if (answers.version) { + // Use || not ?? to treat empty string as falsy (fall back to default/rc) + const prereleasePrefix = answers.prereleasePrefix || defaultPrereleasePrefix; + version = new Version(oldVersion, answers.version, {prereleasePrefix}); + } else if (answers.customVersion) { + version = answers.customVersion; + } else { + version = options.version; + } + return { ...options, - version: answers.version || answers.customVersion || options.version, + version, tag: answers.tag || answers.customTag || options.tag, publishScoped: alreadyPublicScoped || answers.publishScoped, confirm: true, diff --git a/test/ui/prompts/tags.js b/test/ui/prompts/tags.js index ffc3776d..5c7446be 100644 --- a/test/ui/prompts/tags.js +++ b/test/ui/prompts/tags.js @@ -49,6 +49,7 @@ test('choose next', testUi, { tags: ['next'], answers: { version: 'prerelease', + prereleasePrefix: '', tag: 'next', }, }, ({t, results: {version, tag}}) => { @@ -61,6 +62,7 @@ test('choose beta', testUi, { tags: ['beta', 'stable'], answers: { version: 'prerelease', + prereleasePrefix: '', tag: 'beta', }, }, ({t, results: {version, tag}}) => { @@ -73,6 +75,7 @@ test('choose custom', testUi, { tags: ['next'], answers: { version: 'prerelease', + prereleasePrefix: '', tag: 'Other (specify)', customTag: 'alpha', }, @@ -86,6 +89,7 @@ test('choose custom - validation', testUi, { tags: ['next'], answers: { version: 'prerelease', + prereleasePrefix: '', tag: 'Other (specify)', customTag: [ { @@ -124,6 +128,7 @@ for (const {version, expected} of fixtures) { tags: ['next'], answers: { version, + prereleasePrefix: '', tag: 'next', }, }, ({t, results: {version, tag}}) => { diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index 4090535a..e2621fec 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -67,6 +67,7 @@ test('choose patch', testUi, { test('choose premajor', testUi, { version: '0.0.0', answers: { version: 'premajor', + prereleasePrefix: '', }, }, ({t, results: {version}}) => { t.is(version.toString(), '1.0.0-0'); @@ -75,6 +76,7 @@ test('choose premajor', testUi, { test('choose preminor', testUi, { version: '0.0.0', answers: { version: 'preminor', + prereleasePrefix: '', }, }, ({t, results: {version}}) => { t.is(version.toString(), '0.1.0-0'); @@ -83,6 +85,7 @@ test('choose preminor', testUi, { test('choose prepatch', testUi, { version: '0.0.0', answers: { version: 'prepatch', + prereleasePrefix: '', }, }, ({t, results: {version}}) => { t.is(version.toString(), '0.0.1-0'); @@ -91,6 +94,7 @@ test('choose prepatch', testUi, { test('choose prerelease', testUi, { version: '0.0.1-0', answers: { version: 'prerelease', + prereleasePrefix: '', }, }, ({t, results: {version}}) => { t.is(version.toString(), '0.0.1-1'); @@ -133,3 +137,65 @@ test('choose custom - validation', testUi, { }, ({t, results: {version}}) => { t.is(version.toString(), '2.0.0'); }); + +test('choose prepatch with custom prerelease identifier', testUi, { + version: '1.0.0', + answers: { + version: 'prepatch', + prereleasePrefix: 'beta', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.1-beta.0'); +}); + +test('choose preminor with custom prerelease identifier', testUi, { + version: '1.0.0', + answers: { + version: 'preminor', + prereleasePrefix: 'alpha', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.1.0-alpha.0'); +}); + +test('choose premajor with custom prerelease identifier', testUi, { + version: '1.0.0', + answers: { + version: 'premajor', + prereleasePrefix: 'rc', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '2.0.0-rc.0'); +}); + +test('choose prerelease with custom prerelease identifier', testUi, { + version: '1.0.0-rc.0', + answers: { + version: 'prerelease', + prereleasePrefix: 'rc', + }, +}, ({t, results: {version}}) => { + t.is(version.toString(), '1.0.0-rc.1'); +}); + +test('uses current prerelease identifier when available', testUi, { + version: '1.0.2-beta.3', // Current version has 'beta' identifier + answers: { + version: 'prerelease', + prereleasePrefix: 'beta', // Accept the default which should be 'beta' + }, +}, ({t, results: {version}}) => { + // Should use 'beta' from current version + t.is(version.toString(), '1.0.2-beta.4'); +}); + +test('handles numeric prerelease identifiers', testUi, { + version: '1.0.0-0', // Numeric prerelease identifier + answers: { + version: 'prerelease', + prereleasePrefix: 'beta', // Should not suggest '0', user enters 'beta' + }, +}, ({t, results: {version}}) => { + // Should transition from numeric to string identifier + t.is(version.toString(), '1.0.0-beta.0'); +}); From 7009b777a598c69efea195a514fc95528a73a148 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 21:19:21 +0700 Subject: [PATCH 151/180] Fix Yarn Berry detection when `packageManager` field is missing Fixes #752 --- source/package-manager/index.js | 12 ++++- test/package-manager.js | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 test/package-manager.js diff --git a/source/package-manager/index.js b/source/package-manager/index.js index e779cb3c..a9d1b7d4 100644 --- a/source/package-manager/index.js +++ b/source/package-manager/index.js @@ -55,7 +55,17 @@ function configFromPackageManagerField(package_) { /** @param {string} rootDirectory */ function configFromLockfile(rootDirectory, options = [configs.npmConfig, configs.pnpmConfig, configs.yarnConfig]) { - return options.find(config => findLockfile(rootDirectory, config)); + const foundConfig = options.find(config => findLockfile(rootDirectory, config)); + + // If yarn.lock is found, check if it's Yarn Berry by looking for .yarnrc.yml + if (foundConfig === configs.yarnConfig) { + const yarnrcYmlPath = path.resolve(rootDirectory || '.', '.yarnrc.yml'); + if (fs.existsSync(yarnrcYmlPath)) { + return configs.yarnBerryConfig; + } + } + + return foundConfig; } /** @param {import('./types.d.ts').Command} command */ diff --git a/test/package-manager.js b/test/package-manager.js new file mode 100644 index 00000000..a4b82229 --- /dev/null +++ b/test/package-manager.js @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import test from 'ava'; +import {getPackageManagerConfig} from '../source/package-manager/index.js'; +import { + npmConfig, + yarnConfig, + yarnBerryConfig, + pnpmConfig, +} from '../source/package-manager/configs.js'; + +test('detects npm from package-lock.json', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'package-lock.json'), ''); + + const config = getPackageManagerConfig(temporaryDirectory, {name: 'test', version: '1.0.0'}); + + t.is(config, npmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects pnpm from pnpm-lock.yaml', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'pnpm-lock.yaml'), ''); + + const config = getPackageManagerConfig(temporaryDirectory, {name: 'test', version: '1.0.0'}); + + t.is(config, pnpmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Classic from yarn.lock without .yarnrc.yml', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'yarn.lock'), ''); + + const config = getPackageManagerConfig(temporaryDirectory, {name: 'test', version: '1.0.0'}); + + t.is(config, yarnConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Berry from yarn.lock with .yarnrc.yml', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'yarn.lock'), ''); + fs.writeFileSync(path.join(temporaryDirectory, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + + const config = getPackageManagerConfig(temporaryDirectory, {name: 'test', version: '1.0.0'}); + + t.is(config, yarnBerryConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Berry from packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + packageManager: 'yarn@3.0.0', + }); + + t.is(config, yarnBerryConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Classic from packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + packageManager: 'yarn@1.22.0', + }); + + t.is(config, yarnConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); From 1ef9af7832dc10c90629b083f84e207787bb7c31 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 21 Jan 2026 21:39:46 +0700 Subject: [PATCH 152/180] Support passkey authentication via automatic browser opening Fixes #726 --- source/index.js | 2 +- source/npm/publish.js | 54 ++++++++++++++++++++++++++++++++++++++++--- test/index.js | 16 ++++++------- test/npm/publish.js | 14 ++++++----- 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/source/index.js b/source/index.js index 86a13c8a..26c63eec 100644 --- a/source/index.js +++ b/source/index.js @@ -197,7 +197,7 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root task(context, task) { let hasError = false; - return from(runPublish(getPublishCommand(options), {cwd: options.rootDirectory})) + return runPublish(getPublishCommand(options), {cwd: options.rootDirectory}) .pipe(catchError(error => handleNpmError(error, task, otp => { context.otp = otp; diff --git a/source/npm/publish.js b/source/npm/publish.js index 1bade19f..dbb3a967 100644 --- a/source/npm/publish.js +++ b/source/npm/publish.js @@ -1,4 +1,6 @@ import {execa} from 'execa'; +import {merge, filter, catchError} from 'rxjs'; +import open from 'open'; export const getPackagePublishArguments = options => { const arguments_ = ['publish']; @@ -28,8 +30,7 @@ const publishTimeout = 180_000; export function runPublish(arguments_, options = {}) { const execaOptions = { - // Inherit stdin to allow password/OTP prompts from npm/yarn - stdin: 'inherit', + stdin: 'pipe', // Timeout to prevent infinite hangs (e.g., from lifecycle scripts in watch mode) timeout: publishTimeout, }; @@ -41,5 +42,52 @@ export function runPublish(arguments_, options = {}) { execaOptions.cwd = options.cwd; } - return execa(...arguments_, execaOptions); + const subprocess = execa(...arguments_, execaOptions); + + let outputBuffer = ''; + + const handleAuthPrompt = data => { + outputBuffer += data.toString(); + + // Detect npm's browser authentication prompt + // Example: "Authenticate your account at:\nhttps://www.npmjs.com/auth/cli/xyz" + if (outputBuffer.includes('Authenticate your account at:')) { + const urlMatch = outputBuffer.match(/https:\/\/www\.npmjs\.com\/auth\/cli\/\S+/); + if (urlMatch) { + const authUrl = urlMatch[0]; + // Auto-open browser for authentication (ignore errors if browser fails to open) + (async () => { + try { + await open(authUrl); + } catch {} + })(); + + // Automatically send ENTER to continue (skip "Press ENTER" prompt) + subprocess.stdin?.write('\n'); + // Clear buffer after handling to prevent repeated triggers + outputBuffer = ''; + } + } + + // Prevent buffer from growing indefinitely + if (outputBuffer.length > 10_000) { + outputBuffer = outputBuffer.slice(-5000); + } + }; + + // Monitor both stdout and stderr for the authentication prompt + subprocess.stdout?.on('data', handleAuthPrompt); + subprocess.stderr?.on('data', handleAuthPrompt); + + return merge(subprocess.stdout, subprocess.stderr, subprocess).pipe( + filter(Boolean), + catchError(error => { + // Include stderr in error message for better diagnostics + if (error.stderr) { + error.message = `${error.shortMessage}\n${error.stderr}`; + } + + throw error; + }), + ); } diff --git a/test/index.js b/test/index.js index 825866aa..42e38fc0 100644 --- a/test/index.js +++ b/test/index.js @@ -2,6 +2,7 @@ import process from 'node:process'; import test from 'ava'; import sinon from 'sinon'; import esmock from 'esmock'; +import {of, throwError} from 'rxjs'; import {npmConfig as packageManager} from '../source/package-manager/configs.js'; import * as util from '../source/util.js'; @@ -58,6 +59,10 @@ const fakeExecaReturn = () => Object.assign( {stdout: '', stderr: ''}, ); +const fakeObservableReturn = () => of(''); + +const fakeObservableReject = error => throwError(() => Object.assign(new Error(error), {stdout: '', stderr: error})); + test('skip enabling 2FA if the package exists', async t => { const enable2faStub = sinon.stub(); @@ -75,7 +80,7 @@ test('skip enabling 2FA if the package exists', async t => { '../source/npm/enable-2fa.js': enable2faStub, '../source/npm/publish.js': { getPackagePublishArguments: sinon.stub().returns([]), - runPublish: sinon.stub().returns(fakeExecaReturn()), + runPublish: sinon.stub().returns(fakeObservableReturn()), }, }); @@ -107,7 +112,7 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { '../source/npm/enable-2fa.js': enable2faStub, '../source/npm/publish.js': { getPackagePublishArguments: sinon.stub().returns([]), - runPublish: sinon.stub().returns(fakeExecaReturn()), + runPublish: sinon.stub().returns(fakeObservableReturn()), }, }); @@ -123,11 +128,6 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { t.true(enable2faStub.notCalled); }); -const fakeExecaReject = error => Object.assign( - Promise.reject(Object.assign(new Error(error), {stdout: '', stderr: error})), - {stdout: '', stderr: ''}, -); - test('rollback is called when publish fails', async t => { const deleteTagStub = sinon.stub().resolves(); const removeLastCommitStub = sinon.stub().resolves(); @@ -149,7 +149,7 @@ test('rollback is called when publish fails', async t => { '../source/npm/enable-2fa.js': sinon.stub(), '../source/npm/publish.js': { getPackagePublishArguments: sinon.stub().returns([]), - runPublish: sinon.stub().returns(fakeExecaReject('npm ERR! publish failed')), + runPublish: sinon.stub().returns(fakeObservableReject('npm ERR! publish failed')), }, '../source/util.js': { ...util, diff --git a/test/npm/publish.js b/test/npm/publish.js index 0a1b3d7c..42f6cb8e 100644 --- a/test/npm/publish.js +++ b/test/npm/publish.js @@ -1,4 +1,5 @@ import test from 'ava'; +import {firstValueFrom} from 'rxjs'; import {getPackagePublishArguments, runPublish} from '../../source/npm/publish.js'; test('no options set', t => { @@ -37,13 +38,14 @@ test('options.provenance', t => { }); test('runPublish uses cwd option when provided', async t => { - const result = await runPublish(['echo', ['test']], {cwd: '/tmp'}); - t.is(result.cwd, '/tmp'); + const observable = runPublish(['echo', ['test']], {cwd: '/tmp'}); + // Should complete successfully + await t.notThrowsAsync(firstValueFrom(observable)); }); -test('runPublish sets stdin to inherit and includes timeout', async t => { - const result = runPublish(['echo', ['test']]); - t.not(result, undefined); +test('runPublish returns an Observable that completes successfully', async t => { + const observable = runPublish(['echo', ['test']]); + t.not(observable, undefined); // Process should complete successfully with our default options - await t.notThrowsAsync(result); + await t.notThrowsAsync(firstValueFrom(observable)); }); From 02b4ed985f125a0b64fd87c9a7a63a82236c3f2e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 22 Jan 2026 00:08:25 +0700 Subject: [PATCH 153/180] Fix repository URL parsing for shorthand and GitHub Enterprise URLs Fixes #647 Fixes #357 --- source/ui.js | 18 +- source/util.js | 89 ++++++ test/ui/repo-url.js | 47 +++ test/util/parse-git-url.js | 568 +++++++++++++++++++++++++++++++++++++ 4 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 test/ui/repo-url.js create mode 100644 test/util/parse-git-url.js diff --git a/source/ui.js b/source/ui.js index 228c01ed..e89dc4f9 100644 --- a/source/ui.js +++ b/source/ui.js @@ -1,6 +1,7 @@ import inquirer from 'inquirer'; import chalk from 'chalk'; import githubUrlFromGit from 'github-url-from-git'; +import hostedGitInfo from 'hosted-git-info'; import {htmlEscape} from 'escape-goat'; import isScoped from 'is-scoped'; import isInteractive from 'is-interactive'; @@ -153,7 +154,22 @@ const displayUnpublishedFilesWarning = unpublishedFiles => { 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 repoUrl = package_.repository && (() => { + // Try to parse with hosted-git-info first to handle shorthand URLs like "github:foo/bar" + const gitInfo = hostedGitInfo.fromUrl(package_.repository.url); + if (gitInfo?.browse) { + return gitInfo.browse({noCommittish: true}); + } + + // Fall back to github-url-from-git for GitLab and other known hosts + const githubUrl = githubUrlFromGit(package_.repository.url, {extraBaseUrls}); + if (githubUrl) { + return githubUrl; + } + + // Final fallback: parse any git URL format (handles GitHub Enterprise and other hosts) + return util.parseGitUrl(package_.repository.url); + })(); const {stdout: registryUrl} = await execa(...packageManager.getRegistryCommand); const releaseBranch = options.branch; diff --git a/source/util.js b/source/util.js index f99a841b..194ba94c 100644 --- a/source/util.js +++ b/source/util.js @@ -65,6 +65,95 @@ export const linkifyCommitRange = (url, commitRange) => { return terminalLink(commitRange, `${url}/compare/${commitRange}`); }; +/* +Git URL patterns for parsing various formats. + +Patterns use greedy matching + cleanRepo logic to handle edge cases like: +- URLs with double .git suffix (repo.git.git) +- Repos with .git in their name (my.git.git where repo is my.git) + +Using [^\s/?#] to exclude whitespace, query params (?), and fragments (#) +*/ +const GIT_URL_PATTERNS = [ + /// https://host/owner/repo.git or https://host/owner/repo + // Case-insensitive protocol matching via /i flag + { + regex: /^https?:\/\/([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)(\.git)?$/i, + transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, + }, + /// git@host:owner/repo.git (common SSH format) + // Using [^\s:?#] and [^\s/?#] creates clear boundaries + { + regex: /^git@([^\s:?#]+):([^\s/?#]+)\/([^\s/?#]+)\.git$/, + transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, + }, + /// git+https://host/owner/repo.git + { + regex: /^git\+https:\/\/([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)\.git$/i, + transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, + }, + /// ssh://git@host/owner/repo.git + { + regex: /^ssh:\/\/git@([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)\.git$/i, + transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, + }, +]; + +/** +Parse a git URL to extract the HTTPS browse URL. + +Handles various git URL formats including GitHub Enterprise. + +This function uses carefully crafted regex patterns that avoid ReDoS vulnerabilities: +- All patterns are anchored with ^ and $ to prevent partial matches +- Character classes use negated sets [^...] which are linear-time +- No nested quantifiers or overlapping alternatives +- Greedy quantifiers with explicit bounds prevent exponential backtracking + +@param {string} url - The git URL to parse. +@returns {string | undefined} - The HTTPS browse URL or undefined if parsing fails. + +@example +``` +parseGitUrl('git@github.com:owner/repo.git'); +//=> 'https://github.com/owner/repo' + +parseGitUrl('https://github.com/owner/repo.git'); +//=> 'https://github.com/owner/repo' + +parseGitUrl('github:owner/repo'); +//=> undefined (use hosted-git-info for this) +``` +*/ +export const parseGitUrl = url => { + if (typeof url !== 'string' || url.length === 0) { + return; + } + + for (const {regex, transform} of GIT_URL_PATTERNS) { + const match = url.match(regex); + if (match) { + const [, host, owner, repo] = match; + + // Remove .git suffix if present in the captured repo name + const cleanRepo = repo.endsWith('.git') ? repo.slice(0, -4) : repo; + + // Validate that none of the components are empty + if (!host || !owner || !cleanRepo) { + continue; + } + + // Validate that owner and repo contain at least one alphanumeric character + // This prevents pathological inputs like all dots or special chars + if (!/[a-z\d]/i.test(owner) || !/[a-z\d]/i.test(cleanRepo)) { + continue; + } + + return transform(host, owner, cleanRepo); + } + } +}; + /** @type {(config: import('./package-manager/types.js').PackageManagerConfig) => Promise} */ export const getTagVersionPrefix = pMemoize(async config => { assert(config && Object.hasOwn(config, 'tagVersionPrefixCommand'), 'Config is missing key `tagVersionPrefixCommand`'); diff --git a/test/ui/repo-url.js b/test/ui/repo-url.js new file mode 100644 index 00000000..6d33f152 --- /dev/null +++ b/test/ui/repo-url.js @@ -0,0 +1,47 @@ +import test from 'ava'; +import sinon from 'sinon'; +import {npmConfig as packageManager} from '../../source/package-manager/configs.js'; +import {mockInquirer} from '../_helpers/mock-inquirer.js'; + +test('strips committish from hosted git info browse url', async t => { + const {ui} = await mockInquirer({ + t, + answers: {confirm: true}, + mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves(''), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const results = await ui({ + packageManager, + runPublish: false, + availability: {}, + version: 'patch', + }, { + package_: { + name: 'foo', + version: '1.0.0', + files: ['*'], + repository: { + url: 'git+https://github.com/org/repo.git#main', + }, + }, + rootDirectory: '/tmp', + }); + + t.is(results.repoUrl, 'https://github.com/org/repo'); +}); diff --git a/test/util/parse-git-url.js b/test/util/parse-git-url.js new file mode 100644 index 00000000..e21ad233 --- /dev/null +++ b/test/util/parse-git-url.js @@ -0,0 +1,568 @@ +import test from 'ava'; +import {parseGitUrl} from '../../source/util.js'; + +// Valid URL formats +test('parses HTTPS URL with .git suffix', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses HTTPS URL without .git suffix', t => { + t.is(parseGitUrl('https://github.com/owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses HTTP URL with .git suffix', t => { + t.is(parseGitUrl('http://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses HTTP URL without .git suffix', t => { + t.is(parseGitUrl('http://github.com/owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses SSH URL (git@host:owner/repo.git)', t => { + t.is(parseGitUrl('git@github.com:owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses git+https URL', t => { + t.is(parseGitUrl('git+https://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('parses ssh:// URL', t => { + t.is(parseGitUrl('ssh://git@github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +// GitHub Enterprise URLs +test('parses GitHub Enterprise HTTPS URL', t => { + t.is( + parseGitUrl('https://github.enterprise.com/org/project.git'), + 'https://github.enterprise.com/org/project', + ); +}); + +test('parses GitHub Enterprise SSH URL', t => { + t.is( + parseGitUrl('git@github.enterprise.com:org/project.git'), + 'https://github.enterprise.com/org/project', + ); +}); + +test('parses GitHub Enterprise with subdomain', t => { + t.is( + parseGitUrl('git@git.company.internal:team/repo.git'), + 'https://git.company.internal/team/repo', + ); +}); + +// Special characters in names +test('handles hyphens in owner and repo names', t => { + t.is(parseGitUrl('https://github.com/my-org/my-repo.git'), 'https://github.com/my-org/my-repo'); +}); + +test('handles underscores in owner and repo names', t => { + t.is(parseGitUrl('https://github.com/my_org/my_repo.git'), 'https://github.com/my_org/my_repo'); +}); + +test('handles dots in owner and repo names', t => { + t.is(parseGitUrl('https://github.com/my.org/my.repo.git'), 'https://github.com/my.org/my.repo'); +}); + +test('handles numbers in owner and repo names', t => { + t.is(parseGitUrl('https://github.com/org123/repo456.git'), 'https://github.com/org123/repo456'); +}); + +test('handles mixed special characters', t => { + t.is( + parseGitUrl('https://github.com/my-org_123/my-repo_456.git'), + 'https://github.com/my-org_123/my-repo_456', + ); +}); + +// Ports in URLs +test('handles HTTPS URL with port', t => { + t.is( + parseGitUrl('https://github.com:443/owner/repo.git'), + 'https://github.com:443/owner/repo', + ); +}); + +test('handles HTTP URL with custom port', t => { + t.is( + parseGitUrl('http://git.company.com:8080/team/project.git'), + 'https://git.company.com:8080/team/project', + ); +}); + +// Edge cases - invalid inputs +test('returns undefined for empty string', t => { + t.is(parseGitUrl(''), undefined); +}); + +test('returns undefined for non-string input (null)', t => { + t.is(parseGitUrl(null), undefined); +}); + +test('returns undefined for non-string input (undefined)', t => { + t.is(parseGitUrl(undefined), undefined); +}); + +test('returns undefined for non-string input (number)', t => { + t.is(parseGitUrl(123), undefined); +}); + +test('returns undefined for non-string input (object)', t => { + t.is(parseGitUrl({}), undefined); +}); + +// Malformed URLs +test('returns undefined for URL missing .git suffix in SSH format', t => { + t.is(parseGitUrl('git@github.com:owner/repo'), undefined); +}); + +test('returns undefined for URL with extra slashes', t => { + t.is(parseGitUrl('https://github.com//owner//repo.git'), undefined); +}); + +test('returns undefined for URL with missing owner', t => { + t.is(parseGitUrl('https://github.com/repo.git'), undefined); +}); + +test('returns undefined for URL with missing repo', t => { + t.is(parseGitUrl('https://github.com/owner/.git'), undefined); +}); + +test('returns undefined for shorthand notation (not handled by this function)', t => { + t.is(parseGitUrl('github:owner/repo'), undefined); + t.is(parseGitUrl('owner/repo'), undefined); +}); + +test('returns undefined for git:// protocol (not supported)', t => { + t.is(parseGitUrl('git://github.com/owner/repo.git'), undefined); +}); + +test('returns undefined for ftp:// protocol', t => { + t.is(parseGitUrl('ftp://github.com/owner/repo.git'), undefined); +}); + +test('returns undefined for URL with path beyond repo', t => { + t.is(parseGitUrl('https://github.com/owner/repo/extra/path.git'), undefined); +}); + +// ReDoS protection tests +test('handles very long hostname without catastrophic backtracking', t => { + const longHost = 'a'.repeat(10_000); + const url = `https://${longHost}.com/owner/repo.git`; + + // This should complete quickly (< 100ms) if ReDoS protection works + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + t.is(result, `https://${longHost}.com/owner/repo`); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles very long owner name without catastrophic backtracking', t => { + const longOwner = 'a'.repeat(10_000); + const url = `https://github.com/${longOwner}/repo.git`; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + t.is(result, `https://github.com/${longOwner}/repo`); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles very long repo name without catastrophic backtracking', t => { + const longRepo = 'a'.repeat(10_000); + const url = `https://github.com/owner/${longRepo}.git`; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + t.is(result, `https://github.com/owner/${longRepo}`); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles pathological input with many slashes without hanging', t => { + const url = 'https://' + '/'.repeat(10_000) + 'github.com/owner/repo.git'; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + // Should fail gracefully and quickly + t.is(result, undefined); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles pathological input with many dots without hanging', t => { + const manyDots = '.'.repeat(10_000); + const url = `https://github.com/owner/${manyDots}repo.git`; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + // This is technically valid (dots followed by alphanumeric), just unusual + // The important part is that it completes quickly without hanging + t.is(result, `https://github.com/owner/${manyDots}repo`); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('rejects repo name that is only dots', t => { + const onlyDots = '.'.repeat(100); + const url = `https://github.com/owner/${onlyDots}.git`; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + // Should reject because repo has no alphanumeric characters + t.is(result, undefined); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles pathological input with alternating patterns without hanging', t => { + const url = 'https://github.com/' + 'a/'.repeat(5000) + 'repo.git'; + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + // Should fail gracefully and quickly + t.is(result, undefined); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +test('handles extremely long malformed URL without hanging', t => { + const url = 'x'.repeat(100_000); + + const start = Date.now(); + const result = parseGitUrl(url); + const duration = Date.now() - start; + + t.is(result, undefined); + t.true(duration < 100, `Parsing took ${duration}ms, expected < 100ms`); +}); + +// Real-world examples +test('parses real GitHub URL', t => { + t.is(parseGitUrl('https://github.com/sindresorhus/np.git'), 'https://github.com/sindresorhus/np'); +}); + +test('parses real GitLab URL', t => { + t.is(parseGitUrl('https://gitlab.com/gitlab-org/gitlab.git'), 'https://gitlab.com/gitlab-org/gitlab'); +}); + +test('parses real Bitbucket URL', t => { + t.is( + parseGitUrl('https://bitbucket.org/atlassian/python-bitbucket.git'), + 'https://bitbucket.org/atlassian/python-bitbucket', + ); +}); + +test('parses self-hosted GitLab instance', t => { + t.is( + parseGitUrl('git@gitlab.company.com:frontend/webapp.git'), + 'https://gitlab.company.com/frontend/webapp', + ); +}); + +// Normalization tests +test('normalizes HTTP to HTTPS', t => { + const result = parseGitUrl('http://github.com/owner/repo.git'); + t.true(result.startsWith('https://'), 'Should convert HTTP to HTTPS'); +}); + +test('removes .git suffix consistently', t => { + const withGit = parseGitUrl('https://github.com/owner/repo.git'); + const withoutGit = parseGitUrl('https://github.com/owner/repo'); + t.is(withGit, withoutGit, 'Should normalize .git suffix'); +}); + +// Case sensitivity tests +test('preserves case in hostnames', t => { + t.is( + parseGitUrl('https://GitHub.COM/owner/repo.git'), + 'https://GitHub.COM/owner/repo', + ); +}); + +test('preserves case in owner and repo names', t => { + t.is( + parseGitUrl('https://github.com/MyOrg/MyRepo.git'), + 'https://github.com/MyOrg/MyRepo', + ); +}); + +// Multiple protocol variations +test('handles git+https with custom host', t => { + t.is( + parseGitUrl('git+https://git.example.com/team/project.git'), + 'https://git.example.com/team/project', + ); +}); + +test('handles ssh:// with custom host', t => { + t.is( + parseGitUrl('ssh://git@git.example.com/team/project.git'), + 'https://git.example.com/team/project', + ); +}); + +// Whitespace handling +test('does not trim whitespace (garbage in, garbage out)', t => { + t.is(parseGitUrl(' https://github.com/owner/repo.git '), undefined); +}); + +test('does not handle URLs with internal whitespace', t => { + t.is(parseGitUrl('https://github.com/owner /repo.git'), undefined); +}); + +// Edge case: Double .git suffix +test('handles double .git suffix correctly', t => { + // With greedy matching, .git.git is interpreted as repo named "repo.git" + // This is the safer assumption (could be an actual repo named repo.git) + t.is(parseGitUrl('https://github.com/owner/repo.git.git'), 'https://github.com/owner/repo.git'); +}); + +test('handles double .git suffix in SSH format', t => { + t.is(parseGitUrl('git@github.com:owner/repo.git.git'), 'https://github.com/owner/repo'); +}); + +test('handles double .git suffix in git+https format', t => { + t.is(parseGitUrl('git+https://github.com/owner/repo.git.git'), 'https://github.com/owner/repo'); +}); + +// Edge case: Repo name contains .git +test('handles repo name containing .git (e.g., my.git.git)', t => { + // The repo is actually named "my.git", the URL is my.git.git + t.is(parseGitUrl('https://github.com/owner/my.git.git'), 'https://github.com/owner/my.git'); +}); + +test('handles repo name that is exactly .git', t => { + // Repo named ".git" (unusual but technically valid - has alphanumeric chars) + t.is(parseGitUrl('https://github.com/owner/.git.git'), 'https://github.com/owner/.git'); +}); + +test('handles repo with multiple .git patterns in name', t => { + t.is(parseGitUrl('https://github.com/owner/my.git.project.git'), 'https://github.com/owner/my.git.project'); +}); + +// Edge case: Scoped packages (@ symbol) +test('handles scoped package notation with @ symbol', t => { + // @ is allowed in owner names (though unusual for git URLs) + t.is(parseGitUrl('https://github.com/@scope/package.git'), 'https://github.com/@scope/package'); +}); + +test('handles SSH URL with @ in path', t => { + // @ is allowed in paths + t.is(parseGitUrl('git@github.com:@scope/package.git'), 'https://github.com/@scope/package'); +}); + +// Edge case: Single character names +test('handles single character owner', t => { + t.is(parseGitUrl('https://github.com/a/repo.git'), 'https://github.com/a/repo'); +}); + +test('handles single character repo', t => { + t.is(parseGitUrl('https://github.com/owner/r.git'), 'https://github.com/owner/r'); +}); + +test('handles single character for both owner and repo', t => { + t.is(parseGitUrl('https://github.com/a/b.git'), 'https://github.com/a/b'); +}); + +// Edge case: IP addresses as hosts +test('handles IPv4 address as host', t => { + t.is(parseGitUrl('https://192.168.1.1/owner/repo.git'), 'https://192.168.1.1/owner/repo'); +}); + +test('handles IPv4 address with port', t => { + t.is(parseGitUrl('https://192.168.1.1:8080/owner/repo.git'), 'https://192.168.1.1:8080/owner/repo'); +}); + +test('handles localhost as host', t => { + t.is(parseGitUrl('https://localhost/owner/repo.git'), 'https://localhost/owner/repo'); +}); + +test('handles localhost with port', t => { + t.is(parseGitUrl('http://localhost:3000/owner/repo.git'), 'https://localhost:3000/owner/repo'); +}); + +// Edge case: Multiple subdomains +test('handles multiple subdomains in host', t => { + t.is( + parseGitUrl('https://git.prod.company.example.com/owner/repo.git'), + 'https://git.prod.company.example.com/owner/repo', + ); +}); + +test('handles deeply nested subdomains', t => { + t.is( + parseGitUrl('https://a.b.c.d.e.f.example.com/owner/repo.git'), + 'https://a.b.c.d.e.f.example.com/owner/repo', + ); +}); + +// Edge case: Protocol variations +test('handles uppercase HTTP protocol', t => { + t.is(parseGitUrl('HTTP://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('handles uppercase HTTPS protocol', t => { + t.is(parseGitUrl('HTTPS://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +test('handles mixed case protocol', t => { + t.is(parseGitUrl('HtTpS://github.com/owner/repo.git'), 'https://github.com/owner/repo'); +}); + +// Edge case: Malformed protocols +test('rejects malformed protocol (htp)', t => { + t.is(parseGitUrl('htp://github.com/owner/repo.git'), undefined); +}); + +test('rejects malformed protocol (htps)', t => { + t.is(parseGitUrl('htps://github.com/owner/repo.git'), undefined); +}); + +test('rejects protocol with extra characters', t => { + t.is(parseGitUrl('httpss://github.com/owner/repo.git'), undefined); +}); + +// Edge case: Numeric owner and repo +test('handles fully numeric owner', t => { + t.is(parseGitUrl('https://github.com/123456/repo.git'), 'https://github.com/123456/repo'); +}); + +test('handles fully numeric repo', t => { + t.is(parseGitUrl('https://github.com/owner/789012.git'), 'https://github.com/owner/789012'); +}); + +test('handles both owner and repo as numbers', t => { + t.is(parseGitUrl('https://github.com/123/456.git'), 'https://github.com/123/456'); +}); + +// Edge case: Special separators +test('rejects SSH URL with slash instead of colon', t => { + t.is(parseGitUrl('git@github.com/owner/repo.git'), undefined); +}); + +test('rejects git@ URL without colon separator', t => { + t.is(parseGitUrl('git@github.comowner/repo.git'), undefined); +}); + +// Edge case: Missing components +test('rejects URL with empty owner (double slash)', t => { + t.is(parseGitUrl('https://github.com//repo.git'), undefined); +}); + +test('rejects URL with empty repo', t => { + t.is(parseGitUrl('https://github.com/owner//.git'), undefined); +}); + +test('rejects SSH URL with empty owner', t => { + t.is(parseGitUrl('git@github.com:/repo.git'), undefined); +}); + +// Edge case: Trailing characters +test('rejects URL with trailing slash after .git', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git/'), undefined); +}); + +test('rejects URL with trailing text after .git', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git/extra'), undefined); +}); + +// Edge case: Special characters in names +test('handles all special chars together in name', t => { + t.is( + parseGitUrl('https://github.com/my-org_123.test/my-repo_456.test.git'), + 'https://github.com/my-org_123.test/my-repo_456.test', + ); +}); + +test('rejects owner with only special characters (no alphanumeric)', t => { + t.is(parseGitUrl('https://github.com/---/repo.git'), undefined); +}); + +test('rejects repo with only special characters (no alphanumeric)', t => { + t.is(parseGitUrl('https://github.com/owner/---.git'), undefined); +}); + +test('rejects owner with only dots (no alphanumeric)', t => { + t.is(parseGitUrl('https://github.com/.../repo.git'), undefined); +}); + +// Edge case: URL-like patterns in repo names +test('handles repo name with dots that looks URL-like', t => { + t.is( + parseGitUrl('https://github.com/owner/example.com.git'), + 'https://github.com/owner/example.com', + ); +}); + +test('handles repo name with colons', t => { + // Colons are valid in URLs, but [^\s/] allows them + t.is( + parseGitUrl('https://github.com/owner/repo:v1.0.git'), + 'https://github.com/owner/repo:v1.0', + ); +}); + +// Edge case: git+ssh protocol (not supported) +test('rejects git+ssh protocol (not supported)', t => { + t.is(parseGitUrl('git+ssh://git@github.com/owner/repo.git'), undefined); +}); + +// Edge case: ssh with different username (not supported) +test('rejects ssh with non-git username', t => { + t.is(parseGitUrl('ssh://user@github.com/owner/repo.git'), undefined); +}); + +test('rejects ssh with numeric username', t => { + t.is(parseGitUrl('ssh://123@github.com/owner/repo.git'), undefined); +}); + +// Edge case: Query parameters and fragments (should reject) +test('rejects URL with query parameters', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git?ref=main'), undefined); +}); + +test('rejects URL with fragment', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git#readme'), undefined); +}); + +test('rejects URL with both query and fragment', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git?ref=main#readme'), undefined); +}); + +// Edge case: Unusual but valid repo names +test('handles repo name starting with dot', t => { + t.is(parseGitUrl('https://github.com/owner/.dotfile.git'), 'https://github.com/owner/.dotfile'); +}); + +test('handles repo name with consecutive dots', t => { + t.is(parseGitUrl('https://github.com/owner/my..repo.git'), 'https://github.com/owner/my..repo'); +}); + +test('handles repo name ending with dash', t => { + t.is(parseGitUrl('https://github.com/owner/repo-.git'), 'https://github.com/owner/repo-'); +}); + +// Edge case: Case sensitivity preservation +test('preserves exact case in all components', t => { + t.is( + parseGitUrl('https://GitHub.COM/MyOrg/MyRepo.git'), + 'https://GitHub.COM/MyOrg/MyRepo', + ); +}); + +test('preserves case in SSH format', t => { + t.is( + parseGitUrl('git@GitHub.COM:MyOrg/MyRepo.git'), + 'https://GitHub.COM/MyOrg/MyRepo', + ); +}); From c6ef15d0d38651dfcc2b8425f6a0e94de1438b80 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 22 Jan 2026 02:18:46 +0700 Subject: [PATCH 154/180] Fix compatibility with some external registries Fixes #420 --- source/npm/util.js | 8 ++++++++ test/npm/util/collaborators.js | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/source/npm/util.js b/source/npm/util.js index c13b51f8..76c23b85 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -110,11 +110,19 @@ export const collaborators = async package_ => { return stdout; } catch (error) { throwIfNpmTimeout(error); + // Ignore non-existing package error if (error.stderr.includes('code E404')) { return false; } + // External registries often don't support this endpoint, so ignore errors. + // The whoami check is sufficient for verifying authentication. + // See: https://github.com/sindresorhus/np/issues/420 + if (isExternalRegistry(package_)) { + return false; + } + throw error; } }; diff --git a/test/npm/util/collaborators.js b/test/npm/util/collaborators.js index a97b2a52..e1f58eed 100644 --- a/test/npm/util/collaborators.js +++ b/test/npm/util/collaborators.js @@ -62,7 +62,7 @@ test('non-existent', createFixture, [{ ); }); -test('error', createFixture, [{ +test('error on default registry', createFixture, [{ command: accessCommand('@private/pkg'), stderr: 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden', }], async ({t, testedModule: {collaborators}}) => { @@ -70,3 +70,17 @@ test('error', createFixture, [{ t.is(stderr, 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden'); }); +test('error on external registry - returns false', createFixture, [{ + command: `${accessCommand('@private/pkg')} --registry http://my-internal-registry.local`, + stderr: 'npm ERR! code E403\nnpm ERR! 403 403 Forbidden', +}], async ({t, testedModule: {collaborators}}) => { + // Errors should return false instead of throwing, since external registries + // often don't support the collaborators endpoint. + // See: https://github.com/sindresorhus/np/issues/420 + t.is(await collaborators({ + name: '@private/pkg', + publishConfig: { + registry: 'http://my-internal-registry.local', + }, + }), false); +}); From 3ed286aa18ba684fec7a6e35ee5d1620d8a499c2 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 22 Jan 2026 02:39:08 +0700 Subject: [PATCH 155/180] Require major version bump when dropping Node.js support Fixes #88 --- source/npm/util.js | 34 +++ source/prerequisite-tasks.js | 38 ++++ source/util.js | 14 ++ test/_helpers/stub-execa.js | 1 + test/tasks/prerequisite-tasks.js | 292 ++++++++++++++++++++++++++ test/util/get-minimum-node-version.js | 40 ++++ 6 files changed, 419 insertions(+) create mode 100644 test/util/get-minimum-node-version.js diff --git a/source/npm/util.js b/source/npm/util.js index 76c23b85..632c9033 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -225,3 +225,37 @@ export const getFilesToBePacked = async rootDirectory => { throw new Error('Failed to parse output of npm pack', {cause: error}); } }; + +export const getPublishedPackageEngines = async package_ => { + const arguments_ = ['view', '--json', package_.name, 'engines']; + + if (package_.publishConfig?.registry) { + arguments_.push('--registry', package_.publishConfig.registry); + } + + try { + const {stdout} = await execa('npm', arguments_, {timeout: npmNetworkTimeout}); + + // Handle empty response (package exists but has no engines field) + if (stdout.trim() === '') { + return undefined; + } + + return JSON.parse(stdout); + } catch (error) { + throwIfNpmTimeout(error); + + // Package doesn't exist yet (first publish) + if (error.stderr?.includes('E404')) { + return undefined; + } + + // External registries often don't support this endpoint, so ignore errors. + // See: https://github.com/sindresorhus/np/issues/420 + if (isExternalRegistry(package_)) { + return undefined; + } + + throw error; + } +}; diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index dffe9b4f..bf1708e5 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -1,6 +1,7 @@ import process from 'node:process'; import Listr from 'listr'; import {execa} from 'execa'; +import semver from 'semver'; import Version from './version.js'; import * as util from './util.js'; import * as git from './git-util.js'; @@ -76,6 +77,43 @@ const prerequisiteTasks = (input, package_, options, packageManager) => { } }, }, + { + title: 'Check for Node.js engine support drop', + enabled: () => !options.yolo && !package_.private, + async task() { + const publishedEngines = await npm.getPublishedPackageEngines(package_); + + // Skip if this is the first publish or if published package has no engines.node + if (!publishedEngines?.node) { + return; + } + + const localNodeEngine = package_.engines?.node; + + // Skip if local package has no engines.node (we can't compare) + if (!localNodeEngine) { + return; + } + + const publishedMinimum = util.getMinimumNodeVersion(publishedEngines.node); + const localMinimum = util.getMinimumNodeVersion(localNodeEngine); + + // Skip if we couldn't parse either version + if (!publishedMinimum || !localMinimum) { + return; + } + + // Check if the minimum Node.js version has increased + if (semver.gt(localMinimum, publishedMinimum)) { + const diff = semver.diff(package_.version, newVersion.toString()); + + // Only major and premajor releases are allowed to drop Node.js support + if (diff !== 'major' && diff !== 'premajor') { + throw new Error(`Dropping Node.js support from ${publishedMinimum} to ${localMinimum} requires a major version bump. The current release is a ${diff} bump.`); + } + } + }, + }, { title: 'Check git tag existence', async task() { diff --git a/source/util.js b/source/util.js index 194ba94c..ee87a96a 100644 --- a/source/util.js +++ b/source/util.js @@ -8,6 +8,7 @@ import terminalLink from 'terminal-link'; import {execa} from 'execa'; import pMemoize from 'p-memoize'; import chalk from 'chalk'; +import semver from 'semver'; import Version from './version.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; @@ -262,3 +263,16 @@ export async function getNpmPackageAccess(package_) { throw error; } } + +export const getMinimumNodeVersion = range => { + if (!range || typeof range !== 'string') { + return undefined; + } + + try { + const minVersion = semver.minVersion(range); + return minVersion?.version; + } catch { + return undefined; + } +}; diff --git a/test/_helpers/stub-execa.js b/test/_helpers/stub-execa.js index c93c8452..0ba70021 100644 --- a/test/_helpers/stub-execa.js +++ b/test/_helpers/stub-execa.js @@ -8,6 +8,7 @@ import {execa} from 'execa'; const defaultCommands = [ {command: 'npm --version', stdout: '10.0.0'}, {command: 'npm ping', stdout: ''}, + {command: 'npm view --json test engines', stdout: ''}, {command: 'git version', stdout: 'git version 2.40.0'}, {command: 'git ls-remote origin HEAD', stdout: 'abc123\tHEAD'}, {command: 'git fetch', stdout: ''}, diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index bf9f38c7..724c70fa 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -54,6 +54,9 @@ test.serial('private package: should disable task pinging npm registry', createF }); test.serial('external registry: should disable task pinging npm registry', createFixture, [{ + command: 'npm view --json test engines --registry http://my.io', + stdout: '', +}, { command: 'git config user.name', stdout: 'Test User', }, { @@ -362,3 +365,292 @@ test.serial('should skip authentication check when OIDC is detected', createFixt assertTaskSkipped(t, 'Verify user is authenticated'); }); + +test.serial('should fail when dropping Node.js support in a minor release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig)), + {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a minor bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should fail when dropping Node.js support in a patch release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.0.1', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.0.1', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig)), + {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a patch bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should not fail when dropping Node.js support in a major release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig))); +}); + +test.serial('should not fail when dropping Node.js support in a premajor release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v2.0.0-0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, npmConfig))); +}); + +test.serial('should not fail when engines.node was not previously set', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig))); +}); + +test.serial('should not fail when first publishing a package', createFixture, [{ + command: 'npm view --json test engines', + exitCode: 1, + stderr: 'E404 Not Found', +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.0.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.0.0', {name: 'test', version: '0.0.0', engines: {node: '>=18'}}, {}, npmConfig))); +}); + +test.serial('should not fail when local package has no engines.node', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); +}); + +test.serial('private package: should disable task checking for Node.js engine support drop', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + const package_ = { + name: 'test', + version: '1.0.0', + private: true, + engines: {node: '>=18'}, + }; + + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {}, npmConfig))); + + assertTaskDisabled(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should not fail when Node.js minimum version stays the same', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=18'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig))); +}); + +test.serial('should not fail when Node.js minimum version is lowered', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=18'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=16'}}, {}, npmConfig))); +}); + +test.serial('should fail when dropping Node.js support in a preminor release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0-0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.1.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, npmConfig)), + {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a preminor bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should fail when dropping Node.js support in a prepatch release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.0.1-0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.0.1-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, npmConfig)), + {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prepatch bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('should fail when dropping Node.js support in a prerelease release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.0.0-1', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.throwsAsync( + run(prerequisiteTasks('1.0.0-1', {name: 'test', version: '1.0.0-0', engines: {node: '>=18'}}, {tag: 'next'}, npmConfig)), + {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prerelease bump.'}, + ); + + assertTaskFailed(t, 'Check for Node.js engine support drop'); +}); + +test.serial('yolo mode: should disable task checking for Node.js engine support drop', createFixture, [{ + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + const package_ = { + name: 'test', + version: '1.0.0', + engines: {node: '>=18'}, + }; + + // Should not throw even though we're dropping Node.js support in a minor release, + // because yolo mode skips this check + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {yolo: true}, npmConfig))); + + assertTaskDisabled(t, 'Check for Node.js engine support drop'); +}); + +test.serial('external registry: should skip engine check when registry returns error', createFixture, [{ + command: 'npm view --json test engines --registry http://my.io', + exitCode: 1, + stderr: 'E405 Method Not Allowed', +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + const package_ = { + name: 'test', + version: '1.0.0', + engines: {node: '>=18'}, + publishConfig: {registry: 'http://my.io'}, + }; + + // Should not throw even though we're dropping Node.js support in a minor release, + // because the external registry doesn't support the npm view endpoint + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {}, npmConfig))); +}); diff --git a/test/util/get-minimum-node-version.js b/test/util/get-minimum-node-version.js new file mode 100644 index 00000000..c1bbff1e --- /dev/null +++ b/test/util/get-minimum-node-version.js @@ -0,0 +1,40 @@ +import test from 'ava'; +import {getMinimumNodeVersion} from '../../source/util.js'; + +test('returns minimum version from >=X range', t => { + t.is(getMinimumNodeVersion('>=18'), '18.0.0'); + t.is(getMinimumNodeVersion('>=18.0.0'), '18.0.0'); + t.is(getMinimumNodeVersion('>=16.14.0'), '16.14.0'); +}); + +test('returns minimum version from ^X range', t => { + t.is(getMinimumNodeVersion('^18'), '18.0.0'); + t.is(getMinimumNodeVersion('^18.0.0'), '18.0.0'); + t.is(getMinimumNodeVersion('^16.14.0'), '16.14.0'); +}); + +test('returns minimum version from X range', t => { + t.is(getMinimumNodeVersion('18'), '18.0.0'); + t.is(getMinimumNodeVersion('18.0.0'), '18.0.0'); +}); + +test('returns minimum version from OR range', t => { + t.is(getMinimumNodeVersion('18 || 20'), '18.0.0'); + t.is(getMinimumNodeVersion('>=16 || >=18'), '16.0.0'); +}); + +test('returns minimum version from complex range', t => { + t.is(getMinimumNodeVersion('>=18.0.0 <20.0.0'), '18.0.0'); + t.is(getMinimumNodeVersion('>=16.14.0 <17.0.0 || >=18.0.0'), '16.14.0'); +}); + +test('returns undefined for invalid input', t => { + t.is(getMinimumNodeVersion(undefined), undefined); + t.is(getMinimumNodeVersion(null), undefined); + t.is(getMinimumNodeVersion(''), undefined); + t.is(getMinimumNodeVersion(123), undefined); +}); + +test('returns undefined for invalid range', t => { + t.is(getMinimumNodeVersion('invalid'), undefined); +}); From 7dcd833dd5aa9f3b68399053180eeb52daf4a8d4 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 22 Jan 2026 03:10:08 +0700 Subject: [PATCH 156/180] Add prerequisite check for package entry points Fixes #82 --- source/index.js | 2 +- source/npm/util.js | 74 +++++++ source/prerequisite-tasks.js | 9 +- test/fixtures/files/missing-bin/index.js | 1 + test/fixtures/files/missing-bin/package.json | 6 + test/fixtures/files/missing-main/package.json | 6 + .../files/missing-main/source/index.js | 1 + test/npm/util/entry-points.js | 193 ++++++++++++++++++ test/tasks/prerequisite-tasks.js | 72 +++---- 9 files changed, 326 insertions(+), 38 deletions(-) create mode 100644 test/fixtures/files/missing-bin/index.js create mode 100644 test/fixtures/files/missing-bin/package.json create mode 100644 test/fixtures/files/missing-main/package.json create mode 100644 test/fixtures/files/missing-main/source/index.js create mode 100644 test/npm/util/entry-points.js diff --git a/source/index.js b/source/index.js index 26c63eec..55de3d21 100644 --- a/source/index.js +++ b/source/index.js @@ -126,7 +126,7 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root { title: 'Prerequisite check', enabled: () => options.runPublish, - task: () => prerequisiteTasks(input, package_, options, packageManager), + task: () => prerequisiteTasks(input, package_, options, {packageManager, rootDirectory}), }, { title: 'Git', diff --git a/source/npm/util.js b/source/npm/util.js index 632c9033..4dfdc675 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -226,6 +226,80 @@ export const getFilesToBePacked = async rootDirectory => { } }; +const isValidEntryPoint = value => typeof value === 'string' && !value.includes('*'); + +const getExportsFiles = exports => { + const files = []; + + const extract = value => { + if (isValidEntryPoint(value)) { + files.push(value); + } else if (typeof value === 'object' && value !== null) { + for (const subvalue of Object.values(value)) { + extract(subvalue); + } + } + }; + + extract(exports); + return files; +}; + +export const getPackageEntryPoints = package_ => { + const entryPoints = []; + + if (isValidEntryPoint(package_.main)) { + entryPoints.push({field: 'main', path: package_.main}); + } + + if (typeof package_.bin === 'string') { + if (isValidEntryPoint(package_.bin)) { + entryPoints.push({field: 'bin', path: package_.bin}); + } + } else if (typeof package_.bin === 'object' && package_.bin !== null) { + for (const [name, binPath] of Object.entries(package_.bin)) { + if (isValidEntryPoint(binPath)) { + entryPoints.push({field: `bin.${name}`, path: binPath}); + } + } + } + + if (package_.exports) { + for (const file of getExportsFiles(package_.exports)) { + entryPoints.push({field: 'exports', path: file}); + } + } + + return entryPoints; +}; + +export const verifyPackageEntryPoints = async (package_, rootDirectory) => { + const packedFiles = new Set(await getFilesToBePacked(rootDirectory)); + const entryPoints = getPackageEntryPoints(package_); + + const seenPaths = new Set(); + const missingEntryPoints = []; + + for (const entryPoint of entryPoints) { + const normalizedPath = entryPoint.path.replace(/^\.\//, ''); + + if (seenPaths.has(normalizedPath)) { + continue; + } + + seenPaths.add(normalizedPath); + + if (!packedFiles.has(normalizedPath)) { + missingEntryPoints.push(entryPoint); + } + } + + if (missingEntryPoints.length > 0) { + const missing = missingEntryPoints.map(({field, path: entryPath}) => ` "${field}": ${entryPath}`).join('\n'); + throw new Error(`Missing entry points in published files:\n${missing}\n\nEnsure these files exist and are included in the "files" field.`); + } +}; + export const getPublishedPackageEngines = async package_ => { const arguments_ = ['view', '--json', package_.name, 'engines']; diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index bf1708e5..0ecbbef3 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -8,7 +8,7 @@ import * as git from './git-util.js'; import * as npm from './npm/util.js'; import {getOidcProvider} from './npm/oidc.js'; -const prerequisiteTasks = (input, package_, options, packageManager) => { +const prerequisiteTasks = (input, package_, options, {packageManager, rootDirectory}) => { const isExternalRegistry = npm.isExternalRegistry(package_); let newVersion; @@ -114,6 +114,13 @@ const prerequisiteTasks = (input, package_, options, packageManager) => { } }, }, + { + title: 'Verify package entry points', + enabled: () => !options.yolo, + async task() { + await npm.verifyPackageEntryPoints(package_, rootDirectory); + }, + }, { title: 'Check git tag existence', async task() { diff --git a/test/fixtures/files/missing-bin/index.js b/test/fixtures/files/missing-bin/index.js new file mode 100644 index 00000000..d02ba545 --- /dev/null +++ b/test/fixtures/files/missing-bin/index.js @@ -0,0 +1 @@ +export default 'foo'; diff --git a/test/fixtures/files/missing-bin/package.json b/test/fixtures/files/missing-bin/package.json new file mode 100644 index 00000000..0a34283f --- /dev/null +++ b/test/fixtures/files/missing-bin/package.json @@ -0,0 +1,6 @@ +{ + "name": "missing-bin", + "version": "0.0.0", + "bin": "./cli.js", + "files": ["index.js"] +} diff --git a/test/fixtures/files/missing-main/package.json b/test/fixtures/files/missing-main/package.json new file mode 100644 index 00000000..8429a91c --- /dev/null +++ b/test/fixtures/files/missing-main/package.json @@ -0,0 +1,6 @@ +{ + "name": "missing-main", + "version": "0.0.0", + "main": "dist/index.js", + "files": ["source"] +} diff --git a/test/fixtures/files/missing-main/source/index.js b/test/fixtures/files/missing-main/source/index.js new file mode 100644 index 00000000..d02ba545 --- /dev/null +++ b/test/fixtures/files/missing-main/source/index.js @@ -0,0 +1 @@ +export default 'foo'; diff --git a/test/npm/util/entry-points.js b/test/npm/util/entry-points.js new file mode 100644 index 00000000..478a765d --- /dev/null +++ b/test/npm/util/entry-points.js @@ -0,0 +1,193 @@ +import path from 'node:path'; +import test from 'ava'; +import * as npm from '../../../source/npm/util.js'; + +const getFixture = name => path.resolve('test', 'fixtures', 'files', name); + +test('getPackageEntryPoints - main', t => { + t.deepEqual( + npm.getPackageEntryPoints({main: 'index.js'}), + [{field: 'main', path: 'index.js'}], + ); + + t.deepEqual( + npm.getPackageEntryPoints({main: './dist/index.js'}), + [{field: 'main', path: './dist/index.js'}], + ); +}); + +test('getPackageEntryPoints - bin as string', t => { + t.deepEqual( + npm.getPackageEntryPoints({name: 'my-cli', bin: './cli.js'}), + [{field: 'bin', path: './cli.js'}], + ); +}); + +test('getPackageEntryPoints - bin as object', t => { + t.deepEqual( + npm.getPackageEntryPoints({bin: {foo: './bin/foo.js', bar: './bin/bar.js'}}), + [ + {field: 'bin.foo', path: './bin/foo.js'}, + {field: 'bin.bar', path: './bin/bar.js'}, + ], + ); +}); + +test('getPackageEntryPoints - exports as string', t => { + t.deepEqual( + npm.getPackageEntryPoints({exports: './index.js'}), + [{field: 'exports', path: './index.js'}], + ); +}); + +test('getPackageEntryPoints - exports with subpaths', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': './index.js', + './foo': './foo.js', + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.js'}, + {field: 'exports', path: './foo.js'}, + ]); +}); + +test('getPackageEntryPoints - exports with conditions', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': { + import: './index.mjs', + require: './index.cjs', + }, + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.mjs'}, + {field: 'exports', path: './index.cjs'}, + ]); +}); + +test('getPackageEntryPoints - exports with nested conditions', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': { + node: { + import: './index.node.mjs', + require: './index.node.cjs', + }, + default: './index.js', + }, + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.node.mjs'}, + {field: 'exports', path: './index.node.cjs'}, + {field: 'exports', path: './index.js'}, + ]); +}); + +test('getPackageEntryPoints - combined main, bin, and exports', t => { + const entryPoints = npm.getPackageEntryPoints({ + main: './index.js', + bin: './cli.js', + exports: { + '.': './index.js', + './cli': './cli.js', + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'main', path: './index.js'}, + {field: 'bin', path: './cli.js'}, + {field: 'exports', path: './index.js'}, + {field: 'exports', path: './cli.js'}, + ]); +}); + +test('getPackageEntryPoints - empty package', t => { + t.deepEqual(npm.getPackageEntryPoints({}), []); + t.deepEqual(npm.getPackageEntryPoints({name: 'foo', version: '1.0.0'}), []); +}); + +test('getPackageEntryPoints - exports with wildcard patterns are skipped', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': './index.js', + './features/*.js': './src/features/*.js', + './utils/*': './src/utils/*', + }, + }); + + // Only non-wildcard exports should be included + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.js'}, + ]); +}); + +test('getPackageEntryPoints - exports with null values are skipped', t => { + const entryPoints = npm.getPackageEntryPoints({ + exports: { + '.': './index.js', + './internal/*': null, + }, + }); + + t.deepEqual(entryPoints, [ + {field: 'exports', path: './index.js'}, + ]); +}); + +test('getPackageEntryPoints - invalid main values are skipped', t => { + t.deepEqual(npm.getPackageEntryPoints({main: null}), []); + t.deepEqual(npm.getPackageEntryPoints({main: 123}), []); + t.deepEqual(npm.getPackageEntryPoints({main: {}}), []); +}); + +test('getPackageEntryPoints - invalid bin values are skipped', t => { + t.deepEqual(npm.getPackageEntryPoints({bin: null}), []); + t.deepEqual(npm.getPackageEntryPoints({bin: 123}), []); + t.deepEqual(npm.getPackageEntryPoints({bin: {foo: null, bar: './bar.js'}}), [ + {field: 'bin.bar', path: './bar.js'}, + ]); +}); + +test('getPackageEntryPoints - duplicate paths from main and exports', t => { + const entryPoints = npm.getPackageEntryPoints({ + main: './index.js', + exports: './index.js', + }); + + // Both are returned (deduplication happens in verifyPackageEntryPoints) + t.deepEqual(entryPoints, [ + {field: 'main', path: './index.js'}, + {field: 'exports', path: './index.js'}, + ]); +}); + +test('verifyPackageEntryPoints - missing main', async t => { + const fixtureDirectory = getFixture('missing-main'); + + await t.throwsAsync( + npm.verifyPackageEntryPoints({main: 'dist/index.js'}, fixtureDirectory), + {message: /Missing entry points.*"main": dist\/index\.js/s}, + ); +}); + +test('verifyPackageEntryPoints - missing bin', async t => { + const fixtureDirectory = getFixture('missing-bin'); + + await t.throwsAsync( + npm.verifyPackageEntryPoints({bin: './cli.js'}, fixtureDirectory), + {message: /Missing entry points.*"bin": \.\/cli\.js/s}, + ); +}); + +test('verifyPackageEntryPoints - valid entry points', async t => { + const fixtureDirectory = getFixture('one-file'); + + await t.notThrowsAsync(npm.verifyPackageEntryPoints({main: 'index.js'}, fixtureDirectory)); +}); diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 724c70fa..cde6ae86 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -31,7 +31,7 @@ test.serial('public-package published on npm registry: should fail when npm regi stderr: 'failed', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('1.0.0', {name: 'test'}, {}, npmConfig)), + run(prerequisiteTasks('1.0.0', {name: 'test'}, {}, {packageManager: npmConfig})), {message: 'Connection to npm registry failed'}, ); @@ -48,7 +48,7 @@ test.serial('private package: should disable task pinging npm registry', createF command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, {packageManager: npmConfig}))); assertTaskDisabled(t, 'Ping npm registry'); }); @@ -66,7 +66,7 @@ test.serial('external registry: should disable task pinging npm registry', creat command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, {packageManager: npmConfig}))); assertTaskDisabled(t, 'Ping npm registry'); }); @@ -84,7 +84,7 @@ test.serial('should fail when npm version does not match range in `package.json` const depRange = npPackage.engines.npm; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: `\`np\` requires npm ${depRange}`}, ); @@ -104,7 +104,7 @@ test.serial('should fail when yarn version does not match range in `package.json const depRange = npPackage.engines.yarn; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, yarnConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: yarnConfig})), {message: `\`np\` requires yarn ${depRange}`}, ); @@ -124,7 +124,7 @@ test.serial('should fail when user is not authenticated at npm registry', create process.env.NODE_ENV = 'P'; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: 'You do not have write permissions required to publish this package.'}, ); @@ -146,7 +146,7 @@ test.serial('should fail when user is not authenticated at external registry', c process.env.NODE_ENV = 'P'; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'http://my.io'}}, {}, {packageManager: npmConfig})), {message: 'You do not have write permissions required to publish this package.'}, ); @@ -168,7 +168,7 @@ test.serial('should use publishConfig.registry even when set to official npm reg process.env.NODE_ENV = 'P'; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'https://registry.npmjs.org/'}}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', publishConfig: {registry: 'https://registry.npmjs.org/'}}, {}, {packageManager: npmConfig})), {message: 'You do not have write permissions required to publish this package.'}, ); @@ -191,7 +191,7 @@ test.serial('private package: should disable task `verify user is authenticated` }], async ({t, testedModule: prerequisiteTasks}) => { process.env.NODE_ENV = 'P'; - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', private: true}, {}, {packageManager: npmConfig}))); process.env.NODE_ENV = 'test'; @@ -205,7 +205,7 @@ test.serial('should fail when git version does not match range in `package.json` const depRange = npPackage.engines.git; await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: `\`np\` requires git ${depRange}`}, ); @@ -218,7 +218,7 @@ test.serial('should fail when git user.name is not set', createFixture, [{ stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: /Git user configuration is not set/}, ); @@ -234,7 +234,7 @@ test.serial('should fail when git user.email is not set', createFixture, [{ stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: /Git user configuration is not set/}, ); @@ -248,7 +248,7 @@ test.serial('should fail when git remote does not exist', createFixture, [{ stderr: 'not found', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: 'not found'}, ); @@ -257,7 +257,7 @@ test.serial('should fail when git remote does not exist', createFixture, [{ test.serial('should fail when version is invalid', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('DDD', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: 'New version DDD should either be one of patch, minor, major, prepatch, preminor, premajor, prerelease, or a valid SemVer version.'}, ); @@ -266,7 +266,7 @@ test.serial('should fail when version is invalid', createFixture, [], async ({t, test.serial('should fail when version is lower than latest version', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('0.1.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: 'New version 0.1.0 should be higher than current version 1.0.0.'}, ); @@ -275,7 +275,7 @@ test.serial('should fail when version is lower than latest version', createFixtu test.serial('should fail when prerelease version of public package without dist tag given', createFixture, [], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: 'You must specify a dist-tag using --tag when publishing a pre-release version. This prevents accidentally tagging unstable versions as "latest". https://docs.npmjs.com/cli/dist-tag'}, ); @@ -292,7 +292,7 @@ test.serial('should not fail when prerelease version of public package with dist command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {tag: 'pre'}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0'}, {tag: 'pre'}, {packageManager: npmConfig}))); }); test.serial('should not fail when prerelease version of private package without dist tag given', createFixture, [{ @@ -305,7 +305,7 @@ test.serial('should not fail when prerelease version of private package without command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-1', {name: 'test', version: '1.0.0', private: true}, {}, {packageManager: npmConfig}))); }); test.serial('should fail when git tag already exists', createFixture, [{ @@ -319,7 +319,7 @@ test.serial('should fail when git tag already exists', createFixture, [{ stdout: 'vvb', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig)), + run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig})), {message: 'Git tag `v2.0.0` already exists.'}, ); @@ -336,7 +336,7 @@ test.serial('checks should pass', createFixture, [{ command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig}))); }); test.serial('should skip authentication check when OIDC is detected', createFixture, [{ @@ -361,7 +361,7 @@ test.serial('should skip authentication check when OIDC is detected', createFixt process.env.NODE_ENV = 'test'; }); - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig}))); assertTaskSkipped(t, 'Verify user is authenticated'); }); @@ -380,7 +380,7 @@ test.serial('should fail when dropping Node.js support in a minor release', crea stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig)), + run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig})), {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a minor bump.'}, ); @@ -401,7 +401,7 @@ test.serial('should fail when dropping Node.js support in a patch release', crea stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('1.0.1', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig)), + run(prerequisiteTasks('1.0.1', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig})), {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a patch bump.'}, ); @@ -421,7 +421,7 @@ test.serial('should not fail when dropping Node.js support in a major release', command: 'git rev-parse --quiet --verify refs/tags/v2.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); }); test.serial('should not fail when dropping Node.js support in a premajor release', createFixture, [{ @@ -437,7 +437,7 @@ test.serial('should not fail when dropping Node.js support in a premajor release command: 'git rev-parse --quiet --verify refs/tags/v2.0.0-0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig}))); }); test.serial('should not fail when engines.node was not previously set', createFixture, [{ @@ -453,7 +453,7 @@ test.serial('should not fail when engines.node was not previously set', createFi command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); }); test.serial('should not fail when first publishing a package', createFixture, [{ @@ -470,7 +470,7 @@ test.serial('should not fail when first publishing a package', createFixture, [{ command: 'git rev-parse --quiet --verify refs/tags/v1.0.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('1.0.0', {name: 'test', version: '0.0.0', engines: {node: '>=18'}}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('1.0.0', {name: 'test', version: '0.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); }); test.serial('should not fail when local package has no engines.node', createFixture, [{ @@ -486,7 +486,7 @@ test.serial('should not fail when local package has no engines.node', createFixt command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0'}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0'}, {}, {packageManager: npmConfig}))); }); test.serial('private package: should disable task checking for Node.js engine support drop', createFixture, [{ @@ -506,7 +506,7 @@ test.serial('private package: should disable task checking for Node.js engine su engines: {node: '>=18'}, }; - await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {}, {packageManager: npmConfig}))); assertTaskDisabled(t, 'Check for Node.js engine support drop'); }); @@ -524,7 +524,7 @@ test.serial('should not fail when Node.js minimum version stays the same', creat command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); }); test.serial('should not fail when Node.js minimum version is lowered', createFixture, [{ @@ -540,7 +540,7 @@ test.serial('should not fail when Node.js minimum version is lowered', createFix command: 'git rev-parse --quiet --verify refs/tags/v1.1.0', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=16'}}, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=16'}}, {}, {packageManager: npmConfig}))); }); test.serial('should fail when dropping Node.js support in a preminor release', createFixture, [{ @@ -557,7 +557,7 @@ test.serial('should fail when dropping Node.js support in a preminor release', c stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('1.1.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, npmConfig)), + run(prerequisiteTasks('1.1.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a preminor bump.'}, ); @@ -578,7 +578,7 @@ test.serial('should fail when dropping Node.js support in a prepatch release', c stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('1.0.1-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, npmConfig)), + run(prerequisiteTasks('1.0.1-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prepatch bump.'}, ); @@ -599,7 +599,7 @@ test.serial('should fail when dropping Node.js support in a prerelease release', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( - run(prerequisiteTasks('1.0.0-1', {name: 'test', version: '1.0.0-0', engines: {node: '>=18'}}, {tag: 'next'}, npmConfig)), + run(prerequisiteTasks('1.0.0-1', {name: 'test', version: '1.0.0-0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prerelease bump.'}, ); @@ -624,7 +624,7 @@ test.serial('yolo mode: should disable task checking for Node.js engine support // Should not throw even though we're dropping Node.js support in a minor release, // because yolo mode skips this check - await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {yolo: true}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {yolo: true}, {packageManager: npmConfig}))); assertTaskDisabled(t, 'Check for Node.js engine support drop'); }); @@ -652,5 +652,5 @@ test.serial('external registry: should skip engine check when registry returns e // Should not throw even though we're dropping Node.js support in a minor release, // because the external registry doesn't support the npm view endpoint - await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {}, npmConfig))); + await t.notThrowsAsync(run(prerequisiteTasks('1.1.0', package_, {}, {packageManager: npmConfig}))); }); From 4d5fb6ac9510a2ca1bacbbdb73f86bba1dd685e6 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 22 Jan 2026 03:43:59 +0700 Subject: [PATCH 157/180] Add `--remote` flag to specify Git remote Fixes #611 --- readme.md | 2 ++ source/cli-implementation.js | 4 ++++ source/git-util.js | 14 ++++++------ source/index.js | 7 +++--- source/prerequisite-tasks.js | 2 +- test/cli.js | 1 + test/git-util/push-graceful.js | 30 +++++++++++++++++++++++++ test/git-util/verify-remote-is-valid.js | 7 ++++++ 8 files changed, 56 insertions(+), 11 deletions(-) diff --git a/readme.md b/readme.md index 5ccb3ad8..b9e41588 100644 --- a/readme.md +++ b/readme.md @@ -74,6 +74,7 @@ $ np --help --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) --package-manager Use a specific package manager (default: 'packageManager' field in package.json) --provenance Publish with npm provenance statements (CI-only) + --remote Git remote to push to (default: origin) Examples $ np @@ -111,6 +112,7 @@ Currently, these are the flags you can configure: - `message` - The commit message used for the version bump. Any `%s` in the string will be replaced with the new version. By default, npm uses `%s` and Yarn uses `v%s`. - `packageManager` - Set the package manager to be used. Defaults to the [packageManager field in package.json](https://nodejs.org/dist/latest-v16.x/docs/api/all.html#all_packages_packagemanager), so only use if you can't update package.json for some reason. - `provenance` - Publish with [npm provenance statements](https://docs.npmjs.com/generating-provenance-statements) (`false` by default). Requires npm 9.5.0+ and a supported CI environment (GitHub Actions or GitLab CI/CD). +- `remote` - Git remote to push tags and commits to. Useful when publishing from a fork where `origin` is your fork and `upstream` is the main repository. For example, this configures `np` to use `unit-test` as a test script, and to use `dist` as the subdirectory to publish: diff --git a/source/cli-implementation.js b/source/cli-implementation.js index f2f0972b..494e7c8e 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -41,6 +41,7 @@ const cli = meow(` --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) --package-manager Use a specific package manager (default: 'packageManager' field in package.json) --provenance Publish with npm provenance statements (CI-only) + --remote Git remote to push to (default: origin) Examples $ np @@ -104,6 +105,9 @@ const cli = meow(` provenance: { type: 'boolean', }, + remote: { + type: 'string', + }, }, }); diff --git a/source/git-util.js b/source/git-util.js index 325a4cea..a4d8eb17 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -190,10 +190,10 @@ export const verifyRemoteHistoryIsClean = async () => { } }; -export const verifyRemoteIsValid = async () => { +export const verifyRemoteIsValid = async remote => { try { // Inherit stdin to allow SSH password prompts for password-protected keys - await execa('git', ['ls-remote', 'origin', 'HEAD'], {stdin: 'inherit', timeout: gitNetworkTimeout}); + await execa('git', ['ls-remote', remote ?? 'origin', 'HEAD'], {stdin: 'inherit', timeout: gitNetworkTimeout}); } catch (error) { throw new Error(error.stderr.replace('fatal:', 'Git fatal error:')); } @@ -255,18 +255,18 @@ export const commitLogFromRevision = async revision => { return stdout; }; -const push = async (tagArgument = '--follow-tags') => { +const push = async (remote, tagArgument = '--follow-tags') => { // Inherit stdin to allow SSH password prompts for password-protected keys - await execa('git', ['push', tagArgument], {stdin: 'inherit', timeout: gitNetworkTimeout}); + await execa('git', ['push', ...(remote ? [remote] : []), tagArgument], {stdin: 'inherit', timeout: gitNetworkTimeout}); }; -export const pushGraceful = async remoteIsOnGitHub => { +export const pushGraceful = async (remoteIsOnGitHub, remote) => { try { - await push(); + await push(remote); } catch (error) { if (remoteIsOnGitHub && error.stderr && error.stderr.includes('GH006')) { // Try to push tags only, when commits can't be pushed due to branch protection - await push('--tags'); + await push(remote, '--tags'); return {pushed: 'tags', reason: 'Branch protection: np can`t push the commits. Push them manually.'}; } diff --git a/source/index.js b/source/index.js index 55de3d21..7ed357e3 100644 --- a/source/index.js +++ b/source/index.js @@ -235,12 +235,13 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root { title: 'Pushing tags', async skip() { - if (!(await git.hasUpstream())) { + if (!options.remote && !(await git.hasUpstream())) { return 'Upstream branch not found; not pushing.'; } if (options.preview) { - return '[Preview] Command not executed: git push --follow-tags.'; + const remote = options.remote ? `${options.remote} ` : ''; + return `[Preview] Command not executed: git push ${remote}--follow-tags.`; } if (publishStatus === 'FAILED' && options.runPublish) { @@ -248,7 +249,7 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root } }, async task() { - pushedObjects = await git.pushGraceful(isOnGitHub); + pushedObjects = await git.pushGraceful(isOnGitHub, options.remote); }, }, ...options.releaseDraft diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 0ecbbef3..3348462e 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -59,7 +59,7 @@ const prerequisiteTasks = (input, package_, options, {packageManager, rootDirect }, { title: 'Check git remote', - task: async () => git.verifyRemoteIsValid(), + task: async () => git.verifyRemoteIsValid(options.remote), }, { title: 'Validate version', diff --git a/test/cli.js b/test/cli.js index b04c4608..1e1c4fb7 100644 --- a/test/cli.js +++ b/test/cli.js @@ -33,6 +33,7 @@ test('flags: --help', cliPasses, cli, '--help', [ '--message Version bump commit message, \'%s\' will be replaced with version (default: \'%s\' with npm and \'v%s\' with yarn)', '--package-manager Use a specific package manager (default: \'packageManager\' field in package.json)', '--provenance Publish with npm provenance statements (CI-only)', + '--remote Git remote to push to (default: origin)', '', 'Examples', '$ np', diff --git a/test/git-util/push-graceful.js b/test/git-util/push-graceful.js index 89d6518b..8999fb7e 100644 --- a/test/git-util/push-graceful.js +++ b/test/git-util/push-graceful.js @@ -34,3 +34,33 @@ test('throws', createFixture, [{ await t.throwsAsync(pushGraceful(false)); }); +test('pushes to custom remote', createFixture, [{ + command: 'git push upstream --follow-tags', + exitCode: 0, +}], async ({t, testedModule: {pushGraceful}}) => { + await t.notThrowsAsync(pushGraceful(false, 'upstream')); +}); + +test('throws with custom remote', createFixture, [{ + command: 'git push upstream --follow-tags', + exitCode: 1, +}], async ({t, testedModule: {pushGraceful}}) => { + await t.throwsAsync(pushGraceful(false, 'upstream')); +}); + +test('pushes tags only to custom remote on branch protection error', createFixture, [ + { + command: 'git push upstream --follow-tags', + stderr: 'GH006', + }, + { + command: 'git push upstream --tags', + exitCode: 0, + }, +], async ({t, testedModule: {pushGraceful}}) => { + const {pushed, reason} = await pushGraceful(true, 'upstream'); + + t.is(pushed, 'tags'); + t.is(reason, 'Branch protection: np can`t push the commits. Push them manually.'); +}); + diff --git a/test/git-util/verify-remote-is-valid.js b/test/git-util/verify-remote-is-valid.js index fea280b4..fe00e6cf 100644 --- a/test/git-util/verify-remote-is-valid.js +++ b/test/git-util/verify-remote-is-valid.js @@ -23,3 +23,10 @@ test('no remote', createIntegrationFixture, async () => { {message: /^Git fatal error:/m}, ); }); + +test('has custom remote', createStubFixture, [{ + command: 'git ls-remote upstream HEAD', + exitCode: 0, +}], async ({t, testedModule: {verifyRemoteIsValid}}) => { + await t.notThrowsAsync(verifyRemoteIsValid('upstream')); +}); From 53904d08cfd9d15683a6356036bfe2bd3e8d2f5b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 22 Jan 2026 04:23:35 +0700 Subject: [PATCH 158/180] Fix `--contents` flag not being respected when publishing Fixes #776 --- source/index.js | 4 ++-- test/index.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/source/index.js b/source/index.js index 7ed357e3..c0fb4dc6 100644 --- a/source/index.js +++ b/source/index.js @@ -197,11 +197,11 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root task(context, task) { let hasError = false; - return runPublish(getPublishCommand(options), {cwd: options.rootDirectory}) + return runPublish(getPublishCommand(options), {cwd: rootDirectory}) .pipe(catchError(error => handleNpmError(error, task, otp => { context.otp = otp; - return runPublish(getPublishCommand({...options, otp}), {cwd: options.rootDirectory}); + return runPublish(getPublishCommand({...options, otp}), {cwd: rootDirectory}); }))) .pipe( // Note: Cannot use `async` here as the `await` will not finish before the error propagates. diff --git a/test/index.js b/test/index.js index 42e38fc0..2d9679d3 100644 --- a/test/index.js +++ b/test/index.js @@ -1,3 +1,4 @@ +import path from 'node:path'; import process from 'node:process'; import test from 'ava'; import sinon from 'sinon'; @@ -168,3 +169,33 @@ test('rollback is called when publish fails', async t => { t.true(deleteTagStub.calledOnce, 'deleteTag should be called once'); t.true(removeLastCommitStub.calledOnce, 'removeLastCommit should be called once'); }); + +test('publish uses rootDirectory from context as cwd', async t => { + const contentsDirectory = path.resolve('dist'); + let publishCwd; + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': sinon.stub(), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().callsFake((_arguments, options) => { + publishCwd = options?.cwd; + return fakeObservableReturn(); + }), + }, + }); + + await npMock('1.0.0', defaultOptions, {package_: npPackageResult.package_, rootDirectory: contentsDirectory}); + + t.is(publishCwd, contentsDirectory, 'publish should use rootDirectory from context as cwd'); +}); From 16628c71931bd6f5f01f5a9f6e2758b9199dce5b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 22 Jan 2026 09:19:10 +0700 Subject: [PATCH 159/180] Minor tweaks --- source/prerequisite-tasks.js | 2 +- test/tasks/prerequisite-tasks.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 3348462e..5de73717 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -109,7 +109,7 @@ const prerequisiteTasks = (input, package_, options, {packageManager, rootDirect // Only major and premajor releases are allowed to drop Node.js support if (diff !== 'major' && diff !== 'premajor') { - throw new Error(`Dropping Node.js support from ${publishedMinimum} to ${localMinimum} requires a major version bump. The current release is a ${diff} bump.`); + throw new Error(`Raising minimum Node.js version from ${publishedMinimum} to ${localMinimum} requires a major version bump. The current release is a ${diff} bump.`); } } }, diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index cde6ae86..1bd1d8ed 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -381,7 +381,7 @@ test.serial('should fail when dropping Node.js support in a minor release', crea }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('1.1.0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig})), - {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a minor bump.'}, + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a minor bump.'}, ); assertTaskFailed(t, 'Check for Node.js engine support drop'); @@ -402,7 +402,7 @@ test.serial('should fail when dropping Node.js support in a patch release', crea }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('1.0.1', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig})), - {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a patch bump.'}, + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a patch bump.'}, ); assertTaskFailed(t, 'Check for Node.js engine support drop'); @@ -558,7 +558,7 @@ test.serial('should fail when dropping Node.js support in a preminor release', c }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('1.1.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), - {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a preminor bump.'}, + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a preminor bump.'}, ); assertTaskFailed(t, 'Check for Node.js engine support drop'); @@ -579,7 +579,7 @@ test.serial('should fail when dropping Node.js support in a prepatch release', c }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('1.0.1-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), - {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prepatch bump.'}, + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prepatch bump.'}, ); assertTaskFailed(t, 'Check for Node.js engine support drop'); @@ -600,7 +600,7 @@ test.serial('should fail when dropping Node.js support in a prerelease release', }], async ({t, testedModule: prerequisiteTasks}) => { await t.throwsAsync( run(prerequisiteTasks('1.0.0-1', {name: 'test', version: '1.0.0-0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), - {message: 'Dropping Node.js support from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prerelease bump.'}, + {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prerelease bump.'}, ); assertTaskFailed(t, 'Check for Node.js engine support drop'); From 45404ba30a1ddf7bc9d6130b4b7b8fe0ca0a76a7 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 22 Jan 2026 09:22:11 +0700 Subject: [PATCH 160/180] 11.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d5bf0d72..92fa49b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "10.3.0", + "version": "11.0.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From e6a8daf046e67ab62d627e61a51431db4513dc00 Mon Sep 17 00:00:00 2001 From: Wills Bithrey Date: Fri, 23 Jan 2026 03:54:48 +0000 Subject: [PATCH 161/180] Fix Trusted Publishing via OIDC (#777) --- source/cli-implementation.js | 30 ++++++++++++++++++------------ source/index.js | 4 +++- test/index.js | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 494e7c8e..4d960289 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -10,6 +10,7 @@ import config from './config.js'; import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; +import {getOidcProvider} from './npm/oidc.js'; import {SEMVER_INCREMENTS} from './version.js'; import ui from './ui.js'; import np from './index.js'; @@ -193,18 +194,23 @@ try { // Check authentication early, before Listr starts (so login can be interactive) if (options.runPublish) { - const externalRegistry = npm.isExternalRegistry(package_) - ? package_.publishConfig.registry - : false; - - try { - await npm.username({externalRegistry}); - } catch (error) { - if (error.isNotLoggedIn && isInteractive()) { - console.log('\nYou must be logged in to publish. Running `npm login`...\n'); - await npm.login({externalRegistry}); - } else { - throw error; + // Skip auth check if OIDC is available (will be handled by npm publish itself) + if (getOidcProvider()) { + console.log('OIDC authentication detected - skipping auth check'); + } else { + const externalRegistry = npm.isExternalRegistry(package_) + ? package_.publishConfig.registry + : false; + + try { + await npm.username({externalRegistry}); + } catch (error) { + if (error.isNotLoggedIn && isInteractive()) { + console.log('\nYou must be logged in to publish. Running `npm login`...\n'); + await npm.login({externalRegistry}); + } else { + throw error; + } } } } diff --git a/source/index.js b/source/index.js index c0fb4dc6..ae05008f 100644 --- a/source/index.js +++ b/source/index.js @@ -23,6 +23,7 @@ import gitTasks from './git-tasks.js'; import {getPackagePublishArguments, runPublish} from './npm/publish.js'; import enable2fa, {getEnable2faArguments} from './npm/enable-2fa.js'; import handleNpmError from './npm/handle-npm-error.js'; +import {getOidcProvider} from './npm/oidc.js'; import releaseTaskHelper from './release-task-helper.js'; import {findLockfile, printCommand} from './package-manager/index.js'; import * as util from './util.js'; @@ -110,7 +111,8 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root } }, {wait: 2000}); - const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !package_.private && !npm.isExternalRegistry(package_); + // Don't enable 2FA when using OIDC (Trusted Publishing) as it's already managed + const shouldEnable2FA = options['2fa'] && options.availability.isAvailable && !options.availability.isUnknown && !package_.private && !npm.isExternalRegistry(package_) && !getOidcProvider(); // To prevent the process from hanging due to watch mode (e.g. when running `vitest`) const ciEnvOptions = {env: {CI: 'true'}}; diff --git a/test/index.js b/test/index.js index 2d9679d3..76a3608d 100644 --- a/test/index.js +++ b/test/index.js @@ -129,6 +129,42 @@ test('skip enabling 2FA if the `2fa` option is false', async t => { t.true(enable2faStub.notCalled); }); +test('skip enabling 2FA in trusted publishing (OIDC) contexts', async t => { + const enable2faStub = sinon.stub(); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': enable2faStub, + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().returns(fakeObservableReturn()), + }, + '../source/npm/oidc.js': { + getOidcProvider: () => 'github', + }, + }); + + await t.notThrowsAsync(npMock('1.0.0', { + ...defaultOptions, + availability: { + isAvailable: true, + isUnknown: false, + }, + '2fa': true, + }, npPackageResult)); + + t.true(enable2faStub.notCalled); +}); + test('rollback is called when publish fails', async t => { const deleteTagStub = sinon.stub().resolves(); const removeLastCommitStub = sinon.stub().resolves(); From 5a7df1466df894cecc7c5adb6497a5793ef6b835 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 23 Jan 2026 09:39:52 +0700 Subject: [PATCH 162/180] Improve Git URL fallback parsing --- source/util.js | 21 +++++-- test/util/parse-git-url.js | 114 +++++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 18 deletions(-) diff --git a/source/util.js b/source/util.js index ee87a96a..1879a4c9 100644 --- a/source/util.js +++ b/source/util.js @@ -74,6 +74,7 @@ Patterns use greedy matching + cleanRepo logic to handle edge cases like: - Repos with .git in their name (my.git.git where repo is my.git) Using [^\s/?#] to exclude whitespace, query params (?), and fragments (#) +Query params and fragments are stripped before matching. */ const GIT_URL_PATTERNS = [ /// https://host/owner/repo.git or https://host/owner/repo @@ -85,21 +86,24 @@ const GIT_URL_PATTERNS = [ /// git@host:owner/repo.git (common SSH format) // Using [^\s:?#] and [^\s/?#] creates clear boundaries { - regex: /^git@([^\s:?#]+):([^\s/?#]+)\/([^\s/?#]+)\.git$/, + regex: /^git@([^\s:?#]+):([^\s/?#]+)\/([^\s/?#]+)(\.git)?$/, transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, }, /// git+https://host/owner/repo.git { - regex: /^git\+https:\/\/([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)\.git$/i, + regex: /^git\+https:\/\/([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)(\.git)?$/i, transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, }, /// ssh://git@host/owner/repo.git { - regex: /^ssh:\/\/git@([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)\.git$/i, + regex: /^ssh:\/\/git@([^\s/?#]+)\/([^\s/?#]+)\/([^\s/?#]+)(\.git)?$/i, transform: (host, owner, repo) => `https://${host}/${owner}/${repo}`, }, ]; +const ALPHANUMERIC_REGEX = /[a-z\d]/i; +const isValidGitPathComponent = value => Boolean(value) && ALPHANUMERIC_REGEX.test(value); + /** Parse a git URL to extract the HTTPS browse URL. @@ -131,8 +135,13 @@ export const parseGitUrl = url => { return; } + const cleanUrl = url.split(/[?#]/, 1)[0]; + if (cleanUrl.length === 0) { + return; + } + for (const {regex, transform} of GIT_URL_PATTERNS) { - const match = url.match(regex); + const match = cleanUrl.match(regex); if (match) { const [, host, owner, repo] = match; @@ -140,13 +149,13 @@ export const parseGitUrl = url => { const cleanRepo = repo.endsWith('.git') ? repo.slice(0, -4) : repo; // Validate that none of the components are empty - if (!host || !owner || !cleanRepo) { + if (!host) { continue; } // Validate that owner and repo contain at least one alphanumeric character // This prevents pathological inputs like all dots or special chars - if (!/[a-z\d]/i.test(owner) || !/[a-z\d]/i.test(cleanRepo)) { + if (!isValidGitPathComponent(owner) || !isValidGitPathComponent(cleanRepo)) { continue; } diff --git a/test/util/parse-git-url.js b/test/util/parse-git-url.js index e21ad233..058b31e1 100644 --- a/test/util/parse-git-url.js +++ b/test/util/parse-git-url.js @@ -22,14 +22,62 @@ test('parses SSH URL (git@host:owner/repo.git)', t => { t.is(parseGitUrl('git@github.com:owner/repo.git'), 'https://github.com/owner/repo'); }); +test('parses SSH URL without .git suffix', t => { + t.is(parseGitUrl('git@github.com:owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses SSH URL with fragment', t => { + t.is(parseGitUrl('git@github.com:owner/repo.git#main'), 'https://github.com/owner/repo'); +}); + +test('parses SSH URL with query', t => { + t.is(parseGitUrl('git@github.com:owner/repo.git?ref=main'), 'https://github.com/owner/repo'); +}); + test('parses git+https URL', t => { t.is(parseGitUrl('git+https://github.com/owner/repo.git'), 'https://github.com/owner/repo'); }); +test('parses git+https URL without .git suffix', t => { + t.is(parseGitUrl('git+https://github.com/owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses git+https URL with fragment', t => { + t.is( + parseGitUrl('git+https://git.company.com/owner/repo.git#main'), + 'https://git.company.com/owner/repo', + ); +}); + +test('parses git+https URL with query and fragment without .git suffix', t => { + t.is( + parseGitUrl('git+https://git.company.com/owner/repo?ref=main#readme'), + 'https://git.company.com/owner/repo', + ); +}); + test('parses ssh:// URL', t => { t.is(parseGitUrl('ssh://git@github.com/owner/repo.git'), 'https://github.com/owner/repo'); }); +test('parses ssh:// URL without .git suffix', t => { + t.is(parseGitUrl('ssh://git@github.com/owner/repo'), 'https://github.com/owner/repo'); +}); + +test('parses ssh:// URL with query', t => { + t.is( + parseGitUrl('ssh://git@git.company.com/owner/repo.git?ref=main'), + 'https://git.company.com/owner/repo', + ); +}); + +test('parses ssh:// URL with fragment without .git suffix', t => { + t.is( + parseGitUrl('ssh://git@git.company.com/owner/repo#main'), + 'https://git.company.com/owner/repo', + ); +}); + // GitHub Enterprise URLs test('parses GitHub Enterprise HTTPS URL', t => { t.is( @@ -45,6 +93,13 @@ test('parses GitHub Enterprise SSH URL', t => { ); }); +test('parses GitHub Enterprise SSH URL without .git suffix', t => { + t.is( + parseGitUrl('git@github.enterprise.com:org/project'), + 'https://github.enterprise.com/org/project', + ); +}); + test('parses GitHub Enterprise with subdomain', t => { t.is( parseGitUrl('git@git.company.internal:team/repo.git'), @@ -84,6 +139,20 @@ test('handles HTTPS URL with port', t => { ); }); +test('handles git+https URL with port', t => { + t.is( + parseGitUrl('git+https://github.com:443/owner/repo.git'), + 'https://github.com:443/owner/repo', + ); +}); + +test('handles ssh:// URL with port', t => { + t.is( + parseGitUrl('ssh://git@github.com:2222/owner/repo.git'), + 'https://github.com:2222/owner/repo', + ); +}); + test('handles HTTP URL with custom port', t => { t.is( parseGitUrl('http://git.company.com:8080/team/project.git'), @@ -113,10 +182,6 @@ test('returns undefined for non-string input (object)', t => { }); // Malformed URLs -test('returns undefined for URL missing .git suffix in SSH format', t => { - t.is(parseGitUrl('git@github.com:owner/repo'), undefined); -}); - test('returns undefined for URL with extra slashes', t => { t.is(parseGitUrl('https://github.com//owner//repo.git'), undefined); }); @@ -304,6 +369,13 @@ test('handles git+https with custom host', t => { ); }); +test('handles git+https without .git suffix on custom host', t => { + t.is( + parseGitUrl('git+https://git.example.com/team/project'), + 'https://git.example.com/team/project', + ); +}); + test('handles ssh:// with custom host', t => { t.is( parseGitUrl('ssh://git@git.example.com/team/project.git'), @@ -311,6 +383,13 @@ test('handles ssh:// with custom host', t => { ); }); +test('handles ssh:// without .git suffix on custom host', t => { + t.is( + parseGitUrl('ssh://git@git.example.com/team/project'), + 'https://git.example.com/team/project', + ); +}); + // Whitespace handling test('does not trim whitespace (garbage in, garbage out)', t => { t.is(parseGitUrl(' https://github.com/owner/repo.git '), undefined); @@ -328,11 +407,11 @@ test('handles double .git suffix correctly', t => { }); test('handles double .git suffix in SSH format', t => { - t.is(parseGitUrl('git@github.com:owner/repo.git.git'), 'https://github.com/owner/repo'); + t.is(parseGitUrl('git@github.com:owner/repo.git.git'), 'https://github.com/owner/repo.git'); }); test('handles double .git suffix in git+https format', t => { - t.is(parseGitUrl('git+https://github.com/owner/repo.git.git'), 'https://github.com/owner/repo'); + t.is(parseGitUrl('git+https://github.com/owner/repo.git.git'), 'https://github.com/owner/repo.git'); }); // Edge case: Repo name contains .git @@ -527,16 +606,27 @@ test('rejects ssh with numeric username', t => { }); // Edge case: Query parameters and fragments (should reject) -test('rejects URL with query parameters', t => { - t.is(parseGitUrl('https://github.com/owner/repo.git?ref=main'), undefined); +test('strips query parameters', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git?ref=main'), 'https://github.com/owner/repo'); +}); + +test('strips fragment', t => { + t.is(parseGitUrl('https://github.com/owner/repo.git#readme'), 'https://github.com/owner/repo'); +}); + +test('strips query and fragment', t => { + t.is( + parseGitUrl('https://github.com/owner/repo.git?ref=main#readme'), + 'https://github.com/owner/repo', + ); }); -test('rejects URL with fragment', t => { - t.is(parseGitUrl('https://github.com/owner/repo.git#readme'), undefined); +test('returns undefined when URL is only query', t => { + t.is(parseGitUrl('?ref=main'), undefined); }); -test('rejects URL with both query and fragment', t => { - t.is(parseGitUrl('https://github.com/owner/repo.git?ref=main#readme'), undefined); +test('returns undefined when URL is only fragment', t => { + t.is(parseGitUrl('#main'), undefined); }); // Edge case: Unusual but valid repo names From bd60acb32d6da7c79af81b5f630bfbd97b0aaaf8 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 23 Jan 2026 10:55:53 +0700 Subject: [PATCH 163/180] 11.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 92fa49b5..a1b90dd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "11.0.0", + "version": "11.0.1", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From ca857c9dcb140b936c57d1d4dc450b5be65eac3c Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 24 Jan 2026 10:10:28 +0700 Subject: [PATCH 164/180] Fix readme --- readme.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/readme.md b/readme.md index b9e41588..7e654dc4 100644 --- a/readme.md +++ b/readme.md @@ -30,12 +30,11 @@ ### Why not - Monorepos are not supported. -- Custom registries are not supported ([but could be with your help](https://github.com/sindresorhus/np/issues/420)). - CI is [not an ideal environment](https://github.com/sindresorhus/np/issues/619#issuecomment-994493179) for `np`. It's meant to be used locally as an interactive tool. ## Prerequisite -- Node.js 18 or later +- Node.js 20 or later - npm 9 or later - Git 2.11 or later From f0859d9bdda12326c07a9b9ac68cf50e4ee3051c Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 29 Jan 2026 10:47:36 +0700 Subject: [PATCH 165/180] Fix: Allow raising Node.js engine requirement in pre-1.0.0 packages Fixes #778 --- source/prerequisite-tasks.js | 3 ++- test/tasks/prerequisite-tasks.js | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 5de73717..80423f61 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -108,7 +108,8 @@ const prerequisiteTasks = (input, package_, options, {packageManager, rootDirect const diff = semver.diff(package_.version, newVersion.toString()); // Only major and premajor releases are allowed to drop Node.js support - if (diff !== 'major' && diff !== 'premajor') { + // For pre-1.0.0 packages, minor bumps are considered breaking changes per semver + if (diff !== 'major' && diff !== 'premajor' && semver.major(package_.version) >= 1) { throw new Error(`Raising minimum Node.js version from ${publishedMinimum} to ${localMinimum} requires a major version bump. The current release is a ${diff} bump.`); } } diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 1bd1d8ed..035e9d83 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -440,6 +440,22 @@ test.serial('should not fail when dropping Node.js support in a premajor release await t.notThrowsAsync(run(prerequisiteTasks('2.0.0-0', {name: 'test', version: '1.0.0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig}))); }); +test.serial('should not fail when dropping Node.js support in a pre-1.0.0 minor release', createFixture, [{ + command: 'npm view --json test engines', + stdout: JSON.stringify({node: '>=16'}), +}, { + command: 'git config user.name', + stdout: 'Test User', +}, { + command: 'git config user.email', + stdout: 'test@example.com', +}, { + command: 'git rev-parse --quiet --verify refs/tags/v0.2.0', + stdout: '', +}], async ({t, testedModule: prerequisiteTasks}) => { + await t.notThrowsAsync(run(prerequisiteTasks('0.2.0', {name: 'test', version: '0.1.0', engines: {node: '>=18'}}, {}, {packageManager: npmConfig}))); +}); + test.serial('should not fail when engines.node was not previously set', createFixture, [{ command: 'npm view --json test engines', stdout: JSON.stringify({}), From 7aac522a97b52bbe68bd3462c9ed9c52dd7dd5a7 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Thu, 29 Jan 2026 10:51:50 +0700 Subject: [PATCH 166/180] 11.0.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a1b90dd0..874e9c18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "11.0.1", + "version": "11.0.2", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 4c58afc098cf0872b89fff35b29d06cf63f09e3b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 25 Mar 2026 19:49:50 +0700 Subject: [PATCH 167/180] Tweaks --- source/git-util.js | 2 ++ source/prerequisite-tasks.js | 1 + 2 files changed, 3 insertions(+) diff --git a/source/git-util.js b/source/git-util.js index a4d8eb17..af7a14eb 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -224,6 +224,8 @@ export const defaultBranch = async () => { throw new Error('Could not infer the default Git branch. Please specify one with the --branch flag or with a np config.'); }; +// Checks local refs after a prior `git fetch`. This is intentional over `git ls-remote` β€” the fetch +// syncs all refs and also catches local-only tags that haven't been pushed yet. const tagExistsOnRemote = async tagName => { try { const {stdout: revInfo} = await execa('git', ['rev-parse', '--quiet', '--verify', `refs/tags/${tagName}`]); diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 80423f61..491b2c20 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -125,6 +125,7 @@ const prerequisiteTasks = (input, package_, options, {packageManager, rootDirect { title: 'Check git tag existence', async task() { + // Fetch is needed so `tagExistsOnRemote` can check local refs. Don't replace with `ls-remote`. await git.fetch(); const tagPrefix = await util.getTagVersionPrefix(packageManager); From 6d4b88eb258b9ef07ad5ce26362e74fcc56685d8 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 4 Apr 2026 23:00:21 +0700 Subject: [PATCH 168/180] Skip missing entry points for lifecycle-built packages (#782) --- source/npm/util.js | 20 +++++++++++++++++++ .../files/failing-prepack-script/index.js | 1 + .../files/failing-prepack-script/package.json | 8 ++++++++ .../package.json | 9 +++++++++ .../files/prepare-script/package.json | 2 +- test/npm/util/entry-points.js | 11 ++++++++++ test/npm/util/packed-files.js | 4 ++++ 7 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/files/failing-prepack-script/index.js create mode 100644 test/fixtures/files/failing-prepack-script/package.json create mode 100644 test/fixtures/files/prepack-generated-entry-point/package.json diff --git a/source/npm/util.js b/source/npm/util.js index 4dfdc675..f537e517 100644 --- a/source/npm/util.js +++ b/source/npm/util.js @@ -210,6 +210,7 @@ export const getFilesToBePacked = async rootDirectory => { '--dry-run', '--json', '--silent', + '--ignore-scripts', // TODO: Remove this once [npm/cli#7354](https://github.com/npm/cli/issues/7354) is resolved. '--foreground-scripts=false', ], {cwd: rootDirectory}); @@ -226,6 +227,21 @@ export const getFilesToBePacked = async rootDirectory => { } }; +const hasPackLifecycleScript = package_ => { + const {scripts} = package_; + + if (typeof scripts !== 'object' || scripts === null) { + return false; + } + + return [ + 'prepare', + 'prepack', + 'prepublish', + 'prepublishOnly', + ].some(scriptName => typeof scripts[scriptName] === 'string'); +}; + const isValidEntryPoint = value => typeof value === 'string' && !value.includes('*'); const getExportsFiles = exports => { @@ -295,6 +311,10 @@ export const verifyPackageEntryPoints = async (package_, rootDirectory) => { } if (missingEntryPoints.length > 0) { + if (hasPackLifecycleScript(package_)) { + return; + } + const missing = missingEntryPoints.map(({field, path: entryPath}) => ` "${field}": ${entryPath}`).join('\n'); throw new Error(`Missing entry points in published files:\n${missing}\n\nEnsure these files exist and are included in the "files" field.`); } diff --git a/test/fixtures/files/failing-prepack-script/index.js b/test/fixtures/files/failing-prepack-script/index.js new file mode 100644 index 00000000..81afa315 --- /dev/null +++ b/test/fixtures/files/failing-prepack-script/index.js @@ -0,0 +1 @@ +console.log('foo'); diff --git a/test/fixtures/files/failing-prepack-script/package.json b/test/fixtures/files/failing-prepack-script/package.json new file mode 100644 index 00000000..7e1ab4f5 --- /dev/null +++ b/test/fixtures/files/failing-prepack-script/package.json @@ -0,0 +1,8 @@ +{ + "name": "foo", + "version": "0.0.0", + "files": ["index.js"], + "scripts": { + "prepack": "exit 1" + } +} diff --git a/test/fixtures/files/prepack-generated-entry-point/package.json b/test/fixtures/files/prepack-generated-entry-point/package.json new file mode 100644 index 00000000..56ea4562 --- /dev/null +++ b/test/fixtures/files/prepack-generated-entry-point/package.json @@ -0,0 +1,9 @@ +{ + "name": "prepack-generated-entry-point", + "version": "0.0.0", + "main": "dist/index.js", + "files": ["dist"], + "scripts": { + "prepack": "node --input-type=module -e \"import fs from 'node:fs'; fs.mkdirSync('dist', {recursive: true}); fs.writeFileSync('dist/index.js', 'export default 1;\\n');\"" + } +} diff --git a/test/fixtures/files/prepare-script/package.json b/test/fixtures/files/prepare-script/package.json index 5a121c9b..d4140564 100644 --- a/test/fixtures/files/prepare-script/package.json +++ b/test/fixtures/files/prepare-script/package.json @@ -3,6 +3,6 @@ "version": "0.0.0", "files": ["index.js"], "scripts": { - "prepare": "echo '> foo@0.0.0 prepare' && echo '> test prepare script'" + "prepare": "echo '[build] compiling'" } } diff --git a/test/npm/util/entry-points.js b/test/npm/util/entry-points.js index 478a765d..f80347c3 100644 --- a/test/npm/util/entry-points.js +++ b/test/npm/util/entry-points.js @@ -191,3 +191,14 @@ test('verifyPackageEntryPoints - valid entry points', async t => { await t.notThrowsAsync(npm.verifyPackageEntryPoints({main: 'index.js'}, fixtureDirectory)); }); + +test('verifyPackageEntryPoints - skipped when prepack script may generate files', async t => { + const fixtureDirectory = getFixture('prepack-generated-entry-point'); + + await t.notThrowsAsync(npm.verifyPackageEntryPoints({ + main: 'dist/index.js', + scripts: { + prepack: 'build', + }, + }, fixtureDirectory)); +}); diff --git a/test/npm/util/packed-files.js b/test/npm/util/packed-files.js index 8ef1e72c..5924a69b 100644 --- a/test/npm/util/packed-files.js +++ b/test/npm/util/packed-files.js @@ -74,3 +74,7 @@ test('doesn\'t show files in .github', verifyPackedFiles, 'dot-github', [ test('handles prepare script output (e.g., Husky)', verifyPackedFiles, 'prepare-script', [ 'index.js', ]); + +test('ignores failing prepack script', verifyPackedFiles, 'failing-prepack-script', [ + 'index.js', +]); From 06a142c635b77cb59a6466ade38d30295b14a225 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 4 Apr 2026 23:10:18 +0700 Subject: [PATCH 169/180] Fix `--release-draft-only` failing with version check error Fixes #781 --- source/ui.js | 9 +++++++++ test/ui/prompts/version.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/source/ui.js b/source/ui.js index e89dc4f9..3f7ef2f8 100644 --- a/source/ui.js +++ b/source/ui.js @@ -221,6 +221,15 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { } } + if (options.releaseDraftOnly) { + return { + ...options, + confirm: true, + repoUrl, + generateReleaseNotes, + }; + } + // Non-interactive mode - return before prompting // But if it's a prerelease without a tag, we need to prompt for the tag if (options.version) { diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index e2621fec..b5e1458d 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -199,3 +199,37 @@ test('handles numeric prerelease identifiers', testUi, { // Should transition from numeric to string identifier t.is(version.toString(), '1.0.0-beta.0'); }); + +test('releaseDraftOnly does not throw on current version', async t => { + const {ui} = await mockInquirer({ + t, answers: {}, mocks: { + './util.js': { + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + previousTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves(''), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const results = await ui({ + packageManager, + runPublish: false, + availability: {}, + releaseDraftOnly: true, + version: '1.0.0', + }, { + package_: { + name: 'foo', + version: '1.0.0', + repository: {url: 'https://github.com/foo/bar'}, + }, + }); + + t.true(results.confirm); + t.is(results.version, '1.0.0'); +}); From 8e119e3930b4c91f1e0566b6e3812630d801fec2 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 4 Apr 2026 23:40:57 +0700 Subject: [PATCH 170/180] 11.0.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 874e9c18..c2dd1ed1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "11.0.2", + "version": "11.0.3", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 2677fcc7e70c388e8a3ea8f9e60624942e823308 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 17 Apr 2026 13:39:22 +0700 Subject: [PATCH 171/180] Run install even with `--no-cleanup` `--no-cleanup` now only skips deleting `node_modules`; install still runs. Use `--yolo` to skip install entirely. Fixes #785 --- readme.md | 4 +- source/cli-implementation.js | 19 ++- source/index.js | 44 ++++- test/cli.js | 2 +- test/index.js | 306 ++++++++++++++++++++++++++++++++++- 5 files changed, 356 insertions(+), 19 deletions(-) diff --git a/readme.md b/readme.md index 7e654dc4..802b0bba 100644 --- a/readme.md +++ b/readme.md @@ -58,7 +58,7 @@ $ np --help Options --any-branch Allow publishing from any branch --branch Name of the release branch (default: main | master) - --no-cleanup Skips cleanup of node_modules + --no-cleanup Skips np's node_modules cleanup step before install --no-tests Skips tests --yolo Skips cleanup and testing --no-publish Skips publishing @@ -97,7 +97,7 @@ Currently, these are the flags you can configure: - `anyBranch` - Allow publishing from any branch (`false` by default). - `branch` - Name of the release branch (`main` or `master` by default). -- `cleanup` - Cleanup `node_modules` (`true` by default). +- `cleanup` - Delete `node_modules` before installing dependencies (`true` by default). Setting it to `false` only skips np's explicit cleanup step; the package manager install command still runs and may replace `node_modules` itself. Use `yolo` to skip install entirely. - `tests` - Run `npm test` (`true` by default). - `yolo` - Skip cleanup and testing (`false` by default). - `publish` - Publish (`true` by default). diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 4d960289..4cf47027 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +import path from 'node:path'; import process from 'node:process'; import logSymbols from 'log-symbols'; import meow from 'meow'; @@ -27,7 +28,7 @@ const cli = meow(` Options --any-branch Allow publishing from any branch --branch Name of the release branch (default: main | master) - --no-cleanup Skips cleanup of node_modules + --no-cleanup Skips np's node_modules cleanup step before install --no-tests Skips tests --yolo Skips cleanup and testing --no-publish Skips publishing @@ -117,13 +118,16 @@ updateNotifier({pkg: cli.pkg}).notify(); /** @typedef {Awaited>['options']} Options */ async function getOptions() { - // Load config from cwd first to get `contents` option before reading package const initialConfig = await config(process.cwd()); const contents = cli.flags.contents ?? initialConfig?.contents; + const packagePath = contents ? path.resolve(process.cwd(), contents) : process.cwd(); - const {package_, rootDirectory} = await util.readPackage(contents); + const {package_, rootDirectory} = await util.readPackage(packagePath); + const projectDirectory = contents ? process.cwd() : rootDirectory; - const localConfig = await config(rootDirectory); + const localConfig = projectDirectory === process.cwd() + ? initialConfig + : await config(projectDirectory); // Filter out undefined CLI flags (not provided by user) const explicitCliFlags = Object.fromEntries(Object.entries(cli.flags).filter(([, value]) => value !== undefined)); @@ -149,7 +153,7 @@ async function getOptions() { package_.packageManager = flags.packageManager; } - const packageManager = getPackageManagerConfig(rootDirectory, package_); + const packageManager = getPackageManagerConfig(projectDirectory, package_); if (packageManager.throwOnExternalRegistry && npm.isExternalRegistry(package_)) { throw new Error(`External registry is not yet supported with ${packageManager.id}.`); @@ -180,13 +184,14 @@ async function getOptions() { return { options: {...options, packageManager}, + projectDirectory, rootDirectory, package_, }; } try { - const {options, rootDirectory, package_} = await getOptions(); + const {options, projectDirectory, rootDirectory, package_} = await getOptions(); if (!options.confirm) { gracefulExit(); @@ -216,7 +221,7 @@ try { } console.log(); // Prints a newline for readability - const newPackage = await np(options.version.toString(), options, {package_, rootDirectory}); + const newPackage = await np(options.version.toString(), options, {package_, projectDirectory, rootDirectory}); if (options.preview || options.releaseDraftOnly) { gracefulExit(); diff --git a/source/index.js b/source/index.js index ae05008f..6985c895 100644 --- a/source/index.js +++ b/source/index.js @@ -1,3 +1,4 @@ +import path from 'node:path'; import {execa} from 'execa'; import {deleteAsync} from 'del'; // NOTE: We intentionally use the original `listr` package instead of `listr2`. @@ -51,9 +52,10 @@ const exec = (command, arguments_, options) => { /** @param {string} input @param {import('./cli-implementation.js').Options} options -@param {{package_: import('read-pkg').NormalizedPackageJson; rootDirectory: string}} context +@param {{package_: import('read-pkg').NormalizedPackageJson; projectDirectory?: string; rootDirectory: string}} context */ -const np = async (input = 'patch', {packageManager, ...options}, {package_, rootDirectory}) => { +const np = async (input = 'patch', {packageManager, ...options}, {package_, projectDirectory, rootDirectory}) => { + projectDirectory ??= rootDirectory; // TODO: Remove sometime far in the future if (options.skipCleanup) { options.cleanup = false; @@ -61,7 +63,8 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; - const lockfile = findLockfile(rootDirectory, packageManager); + const runInstall = !options.yolo; + const lockfile = findLockfile(projectDirectory, packageManager); const isOnGitHub = options.repoUrl && hostedGitInfo.fromUrl(options.repoUrl)?.type === 'github'; const testScript = options.testScript || 'test'; @@ -124,6 +127,17 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root return publishCommand(arguments_); } + function getInstallCommand() { + // `--no-cleanup` only skips np's explicit cleanup task. + // We still use the package manager's normal lockfile-aware install mode, + // even when that install mode replaces `node_modules` itself. + if (lockfile) { + return packageManager.installCommand; + } + + return packageManager.installCommandNoLockfile; + } + const tasks = new Listr([ { title: 'Prerequisite check', @@ -137,17 +151,26 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root { title: 'Cleanup', enabled: () => runCleanup && !lockfile, - task: () => deleteAsync('node_modules'), + skip() { + if (options.preview) { + return '[Preview] Command not executed: delete node_modules.'; + } + }, + task: () => deleteAsync(path.join(projectDirectory, 'node_modules')), }, { title: `Installing dependencies using ${packageManager.id}`, - enabled: () => runCleanup, + enabled: () => runInstall, + skip() { + if (options.preview) { + return `[Preview] Command not executed: ${printCommand(getInstallCommand())}.`; + } + }, task: () => new Listr([ { title: 'Running install command', task() { - const installCommand = lockfile ? packageManager.installCommand : packageManager.installCommandNoLockfile; - return exec(...installCommand); + return exec(...getInstallCommand(), {cwd: projectDirectory}); }, }, { @@ -159,7 +182,12 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, root { title: 'Running tests', enabled: () => runTests, - task: () => exec(packageManager.cli, ['run', testScript], ciEnvOptions), + skip() { + if (options.preview) { + return `[Preview] Command not executed: ${packageManager.cli} run ${testScript}.`; + } + }, + task: () => exec(packageManager.cli, ['run', testScript], {...ciEnvOptions, cwd: projectDirectory}), }, { title: 'Bumping version', diff --git a/test/cli.js b/test/cli.js index 1e1c4fb7..f48f1e4a 100644 --- a/test/cli.js +++ b/test/cli.js @@ -18,7 +18,7 @@ test('flags: --help', cliPasses, cli, '--help', [ 'Options', '--any-branch Allow publishing from any branch', '--branch Name of the release branch (default: main | master)', - '--no-cleanup Skips cleanup of node_modules', + '--no-cleanup Skips np\'s node_modules cleanup step before install', '--no-tests Skips tests', '--yolo Skips cleanup and testing', '--no-publish Skips publishing', diff --git a/test/index.js b/test/index.js index 76a3608d..36497b50 100644 --- a/test/index.js +++ b/test/index.js @@ -208,6 +208,7 @@ test('rollback is called when publish fails', async t => { test('publish uses rootDirectory from context as cwd', async t => { const contentsDirectory = path.resolve('dist'); + const projectDirectory = path.resolve('.'); let publishCwd; /** @type {typeof np} */ @@ -231,7 +232,310 @@ test('publish uses rootDirectory from context as cwd', async t => { }, }); - await npMock('1.0.0', defaultOptions, {package_: npPackageResult.package_, rootDirectory: contentsDirectory}); + await npMock('1.0.0', defaultOptions, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); t.is(publishCwd, contentsDirectory, 'publish should use rootDirectory from context as cwd'); }); + +test('rootDirectory remains the default working directory when projectDirectory is omitted', async t => { + const rootDirectory = path.resolve('dist'); + const deleteAsyncStub = sinon.stub(); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + let publishCwd; + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': sinon.stub(), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().callsFake((_arguments, options) => { + publishCwd = options?.cwd; + return fakeObservableReturn(); + }), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', defaultOptions, {package_: npPackageResult.package_, rootDirectory}); + + t.true(deleteAsyncStub.calledOnceWithExactly(path.join(rootDirectory, 'node_modules'))); + t.deepEqual(execaStub.firstCall.args, ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict'], {cwd: rootDirectory}]); + t.deepEqual(execaStub.secondCall.args, ['npm', ['run', 'test'], {env: {CI: 'true'}, cwd: rootDirectory}]); + t.is(publishCwd, rootDirectory); +}); + +test('install uses projectDirectory from context as cwd', async t => { + const contentsDirectory = path.resolve('dist'); + const projectDirectory = path.resolve('.'); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + tests: false, + publish: false, + runPublish: false, + preview: false, + }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); + + t.deepEqual(execaStub.firstCall.args, ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict'], {cwd: projectDirectory}]); +}); + +test('cleanup uses projectDirectory from context', async t => { + const contentsDirectory = path.resolve('dist'); + const projectDirectory = path.resolve('.'); + const deleteAsyncStub = sinon.stub(); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: sinon.stub().returns(fakeExecaReturn())}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + tests: false, + publish: false, + runPublish: false, + preview: false, + }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); + + t.true(deleteAsyncStub.calledOnceWithExactly(path.join(projectDirectory, 'node_modules'))); +}); + +test('tests use projectDirectory from context as cwd', async t => { + const contentsDirectory = path.resolve('dist'); + const projectDirectory = path.resolve('.'); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + cleanup: false, + publish: false, + runPublish: false, + preview: false, + }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); + + t.deepEqual(execaStub.secondCall.args, ['npm', ['run', 'test'], {env: {CI: 'true'}, cwd: projectDirectory}]); +}); + +test('no-cleanup still uses lockfile-aware install command', async t => { + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns('package-lock.json'), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + cleanup: false, + tests: false, + publish: false, + runPublish: false, + preview: false, + }, npPackageResult); + + t.deepEqual(execaStub.firstCall.args, ['npm', ['ci', '--engine-strict'], {cwd: npPackageResult.rootDirectory}]); +}); + +test('contents mode looks up lockfile in projectDirectory and installs there', async t => { + const projectDirectory = path.resolve('.'); + const rootDirectory = path.resolve('dist'); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + const findLockfileStub = sinon.stub().returns('package-lock.json'); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: findLockfileStub, + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + tests: false, + publish: false, + runPublish: false, + preview: false, + }, {package_: npPackageResult.package_, projectDirectory, rootDirectory}); + + t.true(findLockfileStub.calledOnceWithExactly(projectDirectory, packageManager)); + t.deepEqual(execaStub.firstCall.args, ['npm', ['ci', '--engine-strict'], {cwd: projectDirectory}]); +}); + +test('contents mode keeps cleanup, install, and tests in projectDirectory while publishing from rootDirectory', async t => { + const projectDirectory = path.resolve('.'); + const rootDirectory = path.resolve('dist'); + const deleteAsyncStub = sinon.stub(); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + let publishCwd; + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/npm/enable-2fa.js': sinon.stub(), + '../source/npm/publish.js': { + getPackagePublishArguments: sinon.stub().returns([]), + runPublish: sinon.stub().callsFake((_arguments, options) => { + publishCwd = options?.cwd; + return fakeObservableReturn(); + }), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', defaultOptions, {package_: npPackageResult.package_, projectDirectory, rootDirectory}); + + t.true(deleteAsyncStub.calledOnceWithExactly(path.join(projectDirectory, 'node_modules'))); + t.deepEqual(execaStub.firstCall.args, ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict'], {cwd: projectDirectory}]); + t.deepEqual(execaStub.secondCall.args, ['npm', ['run', 'test'], {env: {CI: 'true'}, cwd: projectDirectory}]); + t.is(publishCwd, rootDirectory); +}); + +test('preview with no-cleanup does not execute install command', async t => { + const execaStub = sinon.stub().returns(fakeExecaReturn()); + const verifyWorkingTreeIsCleanStub = sinon.stub(); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: sinon.stub()}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: verifyWorkingTreeIsCleanStub, + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns('package-lock.json'), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + cleanup: false, + tests: false, + publish: false, + runPublish: false, + preview: true, + }, npPackageResult); + + t.true(execaStub.notCalled); + t.true(verifyWorkingTreeIsCleanStub.notCalled); +}); + +test('preview without lockfile does not clean up or run tests', async t => { + const deleteAsyncStub = sinon.stub(); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + preview: true, + publish: false, + runPublish: false, + }, npPackageResult); + + t.true(deleteAsyncStub.notCalled); + t.true(execaStub.notCalled); +}); From 86a7f2631630e18a3538047f00722c332ddce0a3 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 17 Apr 2026 14:57:00 +0700 Subject: [PATCH 172/180] Fix: Use empty prerelease default for pre-major bump Fixes #786 --- source/ui.js | 3 +- test/_helpers/mock-inquirer.js | 22 ++++++++++----- test/ui/prompts/version.js | 51 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/source/ui.js b/source/ui.js index 3f7ef2f8..9aaa313f 100644 --- a/source/ui.js +++ b/source/ui.js @@ -414,8 +414,7 @@ const ui = async ({packageManager, ...options}, {package_, rootDirectory}) => { prereleasePrefix: { type: 'input', message: 'Prerelease identifier', - // Use || not ?? to treat empty string as falsy (show 'rc' instead of empty default) - default: defaultPrereleasePrefix || 'rc', + default: defaultPrereleasePrefix, when(answers) { // Only ask when a prerelease increment was selected from the menu if (!answers.version) { diff --git a/test/_helpers/mock-inquirer.js b/test/_helpers/mock-inquirer.js index 242af26b..18d87635 100644 --- a/test/_helpers/mock-inquirer.js +++ b/test/_helpers/mock-inquirer.js @@ -24,10 +24,7 @@ Logs for debugging are outputted on test failure. @param {Answers} o.inputAnswers Test input @param {Record | Prompt[]} o.prompts Actual prompts */ -const mockPrompt = async ({t, inputAnswers, prompts}) => { - const answers = {}; - - // Ensure `prompts` is an object +const getPromptsObject = prompts => { if (Array.isArray(prompts)) { const promptsObject = {}; @@ -35,9 +32,16 @@ const mockPrompt = async ({t, inputAnswers, prompts}) => { promptsObject[prompt.name] = prompt; } - prompts = promptsObject; + return promptsObject; } + return prompts; +}; + +const mockPrompt = async ({t, inputAnswers, prompts}) => { + const answers = {}; + prompts = getPromptsObject(prompts); + t.log('prompts:', Object.keys(prompts)); /* eslint-disable no-await-in-loop */ @@ -193,8 +197,9 @@ Mocks `inquirer` for testing `source/ui.js`. @param {ExecutionContext} o.t @param {Answers} o.answers Test input @param {import('esmock').MockMap} [o.mocks] Optional global mocks +@param {(prompts: Record) => void} [o.onPrompt] Optional hook to inspect prompts */ -export const mockInquirer = async ({t, answers, mocks = {}}) => { +export const mockInquirer = async ({t, answers, mocks = {}, onPrompt = () => {}}) => { /** @type {string[]} */ const logs = []; @@ -202,10 +207,13 @@ export const mockInquirer = async ({t, answers, mocks = {}}) => { const ui = await esmock('../../source/ui.js', import.meta.url, { inquirer: { async prompt(prompts) { + const promptDescriptors = getPromptsObject(prompts); + onPrompt(promptDescriptors); + let uiAnswers = {}; const assertions = await t.try(async tt => { - uiAnswers = await mockPrompt({t: tt, inputAnswers: answers, prompts}); + uiAnswers = await mockPrompt({t: tt, inputAnswers: answers, prompts: promptDescriptors}); }); assertions.commit({retainLogs: !assertions.passed}); diff --git a/test/ui/prompts/version.js b/test/ui/prompts/version.js index b5e1458d..a5c872f7 100644 --- a/test/ui/prompts/version.js +++ b/test/ui/prompts/version.js @@ -233,3 +233,54 @@ test('releaseDraftOnly does not throw on current version', async t => { t.true(results.confirm); t.is(results.version, '1.0.0'); }); + +test('uses empty prerelease identifier by default when no prerelease identifier is configured', async t => { + let capturedPrereleasePrefixPrompt; + + const {ui} = await mockInquirer({ + t, + answers: { + version: 'premajor', + prereleasePrefix: '', + }, + onPrompt(prompts) { + capturedPrereleasePrefixPrompt = prompts.prereleasePrefix; + }, + mocks: { + './npm/util.js': { + checkIgnoreStrategy: sinon.stub().resolves(), + }, + './util.js': { + getNewFiles: sinon.stub().resolves({unpublished: [], firstTime: []}), + getNewDependencies: sinon.stub().resolves([]), + getPreReleasePrefix: sinon.stub().resolves(''), + }, + './git-util.js': { + latestTagOrFirstCommit: sinon.stub().resolves('v1.0.0'), + commitLogFromRevision: sinon.stub().resolves('abc 123'), + }, + execa: { + execa: sinon.stub().resolves({stdout: 'https://registry.npmjs.org/'}), + }, + }, + }); + + const {version} = await ui({ + packageManager, + runPublish: false, + availability: {}, + }, { + package_: { + name: 'foo', + version: '1.0.0', + files: ['*'], + repository: { + url: 'https://github.com/foo/bar', + }, + }, + }); + + t.truthy(capturedPrereleasePrefixPrompt); + t.is(capturedPrereleasePrefixPrompt.default, '', 'the prerelease identifier default should remain empty'); + t.is(version.toString(), '2.0.0-0'); +}); From d24cb95ee6503e03c773ea9c4540934b035b241f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 17 Apr 2026 15:35:47 +0700 Subject: [PATCH 173/180] Add `--dry-run` as an alias of `--preview` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It’s actually the other way around, but it’s easier to explain it as an alias since the change is backwards compatible. Fixes #783 --- readme.md | 6 ++--- source/cli-implementation.js | 13 +++++++--- source/index.js | 42 +++++++++++++++++-------------- test/cli.js | 26 ++++++++++++++++++- test/index.js | 48 ++++++++++++++++++++++++++++++------ 5 files changed, 100 insertions(+), 35 deletions(-) diff --git a/readme.md b/readme.md index 802b0bba..e2950e76 100644 --- a/readme.md +++ b/readme.md @@ -23,7 +23,7 @@ (does not apply to external registries) - Opens a prefilled GitHub Releases draft after publish - Warns about the possibility of extraneous files being published -- See exactly what will be executed with [preview mode](https://github.com/sindresorhus/np/issues/391), without pushing or publishing anything remotely +- See exactly what will be executed with [dry-run mode](https://github.com/sindresorhus/np/issues/391), without pushing or publishing anything remotely - Supports [GitHub Packages](https://github.com/features/packages) - Supports npm 9+, Yarn (Classic and Berry), pnpm 8+, and Bun @@ -62,7 +62,7 @@ $ np --help --no-tests Skips tests --yolo Skips cleanup and testing --no-publish Skips publishing - --preview Show tasks without actually executing them + --dry-run Show tasks without actually executing them --tag Publish under a given dist-tag --contents Subdirectory to publish --no-release-draft Skips opening a GitHub release draft @@ -101,7 +101,7 @@ Currently, these are the flags you can configure: - `tests` - Run `npm test` (`true` by default). - `yolo` - Skip cleanup and testing (`false` by default). - `publish` - Publish (`true` by default). -- `preview` - Show tasks without actually executing them (`false` by default). +- `dryRun` - Show tasks without actually executing them (`false` by default). The CLI also accepts `--preview` as an alias. - `tag` - Publish under a given dist-tag (`latest` by default). - `contents` - Subdirectory to publish (`.` by default). - `releaseDraft` - Open a GitHub release draft after releasing (`true` by default). diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 4cf47027..71731c70 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -32,7 +32,7 @@ const cli = meow(` --no-tests Skips tests --yolo Skips cleanup and testing --no-publish Skips publishing - --preview Show tasks without actually executing them + --dry-run Show tasks without actually executing them --tag Publish under a given dist-tag --contents Subdirectory to publish --no-release-draft Skips opening a GitHub release draft @@ -54,6 +54,7 @@ const cli = meow(` `, { importMeta: import.meta, booleanDefault: undefined, + allowUnknownFlags: false, // Don't use `default` for flags - we apply defaults later so config can override them flags: { anyBranch: { @@ -92,8 +93,9 @@ const cli = meow(` contents: { type: 'string', }, - preview: { + dryRun: { type: 'boolean', + aliases: ['preview'], }, testScript: { type: 'string', @@ -133,7 +135,7 @@ async function getOptions() { const explicitCliFlags = Object.fromEntries(Object.entries(cli.flags).filter(([, value]) => value !== undefined)); // Merge: local config β†’ explicit CLI flags β†’ defaults - const flags = { + const mergedFlags = { cleanup: true, tests: true, publish: true, @@ -144,6 +146,9 @@ async function getOptions() { ...explicitCliFlags, }; + const {preview, ...flags} = mergedFlags; + flags.dryRun ??= preview; + // Workaround for unintended auto-casing behavior from `meow`. if ('2Fa' in flags) { flags['2fa'] = flags['2Fa']; @@ -223,7 +228,7 @@ try { console.log(); // Prints a newline for readability const newPackage = await np(options.version.toString(), options, {package_, projectDirectory, rootDirectory}); - if (options.preview || options.releaseDraftOnly) { + if (options.dryRun || options.releaseDraftOnly) { gracefulExit(); } diff --git a/source/index.js b/source/index.js index 6985c895..ff74c6b6 100644 --- a/source/index.js +++ b/source/index.js @@ -54,8 +54,12 @@ const exec = (command, arguments_, options) => { @param {import('./cli-implementation.js').Options} options @param {{package_: import('read-pkg').NormalizedPackageJson; projectDirectory?: string; rootDirectory: string}} context */ -const np = async (input = 'patch', {packageManager, ...options}, {package_, projectDirectory, rootDirectory}) => { +const np = async (input = 'patch', {packageManager, ...rawOptions}, {package_, projectDirectory, rootDirectory}) => { projectDirectory ??= rootDirectory; + + const {preview, ...options} = rawOptions; + options.dryRun ??= preview; + // TODO: Remove sometime far in the future if (options.skipCleanup) { options.cleanup = false; @@ -103,7 +107,7 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj }); asyncExitHook(async () => { - if (options.preview || publishStatus === 'SUCCESS') { + if (options.dryRun || publishStatus === 'SUCCESS') { return; } @@ -152,8 +156,8 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj title: 'Cleanup', enabled: () => runCleanup && !lockfile, skip() { - if (options.preview) { - return '[Preview] Command not executed: delete node_modules.'; + if (options.dryRun) { + return '[Dry run] Command not executed: delete node_modules.'; } }, task: () => deleteAsync(path.join(projectDirectory, 'node_modules')), @@ -162,8 +166,8 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj title: `Installing dependencies using ${packageManager.id}`, enabled: () => runInstall, skip() { - if (options.preview) { - return `[Preview] Command not executed: ${printCommand(getInstallCommand())}.`; + if (options.dryRun) { + return `[Dry run] Command not executed: ${printCommand(getInstallCommand())}.`; } }, task: () => new Listr([ @@ -183,8 +187,8 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj title: 'Running tests', enabled: () => runTests, skip() { - if (options.preview) { - return `[Preview] Command not executed: ${packageManager.cli} run ${testScript}.`; + if (options.dryRun) { + return `[Dry run] Command not executed: ${packageManager.cli} run ${testScript}.`; } }, task: () => exec(packageManager.cli, ['run', testScript], {...ciEnvOptions, cwd: projectDirectory}), @@ -192,14 +196,14 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj { title: 'Bumping version', skip() { - if (options.preview) { + if (options.dryRun) { const [cli, arguments_] = packageManager.versionCommand(input); if (options.message) { arguments_.push('--message', options.message.replaceAll('%s', input)); } - return `[Preview] Command not executed: ${printCommand([cli, arguments_])}`; + return `[Dry run] Command not executed: ${printCommand([cli, arguments_])}`; } }, task() { @@ -218,9 +222,9 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj { title: 'Publishing package', skip() { - if (options.preview) { + if (options.dryRun) { const command = getPublishCommand(options); - return `[Preview] Command not executed: ${printCommand(command)}.`; + return `[Dry run] Command not executed: ${printCommand(command)}.`; } }, /** @type {(context, task) => Listr.ListrTaskResult} */ @@ -252,9 +256,9 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj ? [{ title: 'Enabling two-factor authentication', async skip() { - if (options.preview) { + if (options.dryRun) { const arguments_ = await getEnable2faArguments(package_.name, options); - return `[Preview] Command not executed: npm ${arguments_.join(' ')}.`; + return `[Dry run] Command not executed: npm ${arguments_.join(' ')}.`; } }, task: (context, task) => enable2fa(task, package_.name, {otp: context.otp}), @@ -269,9 +273,9 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj return 'Upstream branch not found; not pushing.'; } - if (options.preview) { + if (options.dryRun) { const remote = options.remote ? `${options.remote} ` : ''; - return `[Preview] Command not executed: git push ${remote}--follow-tags.`; + return `[Dry run] Command not executed: git push ${remote}--follow-tags.`; } if (publishStatus === 'FAILED' && options.runPublish) { @@ -287,8 +291,8 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj title: 'Creating release draft on GitHub', enabled: () => isOnGitHub === true, skip() { - if (options.preview) { - return '[Preview] GitHub Releases draft will not be opened in preview mode.'; + if (options.dryRun) { + return '[Dry run] GitHub Releases draft will not be opened in dry-run mode.'; } }, task: () => releaseTaskHelper(options, package_, packageManager), @@ -297,7 +301,7 @@ const np = async (input = 'patch', {packageManager, ...options}, {package_, proj ], { showSubtasks: false, renderer: options.renderer ?? 'default', - clearOutput: !options.preview && !options.releaseDraftOnly, + clearOutput: !options.dryRun && !options.releaseDraftOnly, }); if (!options.runPublish) { diff --git a/test/cli.js b/test/cli.js index f48f1e4a..fb99705b 100644 --- a/test/cli.js +++ b/test/cli.js @@ -1,5 +1,6 @@ import path from 'node:path'; import test from 'ava'; +import {execa} from 'execa'; import {npPackage, npRootDirectory as rootDirectory} from '../source/util.js'; import {cliPasses} from './_helpers/verify-cli.js'; @@ -22,7 +23,7 @@ test('flags: --help', cliPasses, cli, '--help', [ '--no-tests Skips tests', '--yolo Skips cleanup and testing', '--no-publish Skips publishing', - '--preview Show tasks without actually executing them', + '--dry-run Show tasks without actually executing them', '--tag Publish under a given dist-tag', '--contents Subdirectory to publish', '--no-release-draft Skips opening a GitHub release draft', @@ -45,3 +46,26 @@ test('flags: --help', cliPasses, cli, '--help', [ ]); test('flags: --version', cliPasses, cli, '--version', [npPackage.version]); + +test('flags: --dry-run is shown in help', async t => { + const {stdout} = await execa(cli, ['--help']); + + t.true(stdout.includes('--dry-run Show tasks without actually executing them')); +}); + +test('flags: unknown flags fail', async t => { + const {exitCode, stderr} = await execa(cli, ['--wat'], {reject: false}); + + t.is(exitCode, 2); + t.true(stderr.includes('Unknown flag')); + t.true(stderr.includes('--wat')); +}); + +test('flags: --preview remains an alias for --dry-run', async t => { + const {exitCode, stderr} = await execa(cli, ['--preview', '--wat'], {reject: false}); + + t.is(exitCode, 2); + t.true(stderr.includes('Unknown flag')); + t.true(stderr.includes('--wat')); + t.false(stderr.includes('--preview')); +}); diff --git a/test/index.js b/test/index.js index 36497b50..91729173 100644 --- a/test/index.js +++ b/test/index.js @@ -299,7 +299,7 @@ test('install uses projectDirectory from context as cwd', async t => { tests: false, publish: false, runPublish: false, - preview: false, + dryRun: false, }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); t.deepEqual(execaStub.firstCall.args, ['npm', ['install', '--no-package-lock', '--no-production', '--engine-strict'], {cwd: projectDirectory}]); @@ -332,7 +332,7 @@ test('cleanup uses projectDirectory from context', async t => { tests: false, publish: false, runPublish: false, - preview: false, + dryRun: false, }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); t.true(deleteAsyncStub.calledOnceWithExactly(path.join(projectDirectory, 'node_modules'))); @@ -361,7 +361,7 @@ test('tests use projectDirectory from context as cwd', async t => { cleanup: false, publish: false, runPublish: false, - preview: false, + dryRun: false, }, {package_: npPackageResult.package_, projectDirectory, rootDirectory: contentsDirectory}); t.deepEqual(execaStub.secondCall.args, ['npm', ['run', 'test'], {env: {CI: 'true'}, cwd: projectDirectory}]); @@ -393,7 +393,7 @@ test('no-cleanup still uses lockfile-aware install command', async t => { tests: false, publish: false, runPublish: false, - preview: false, + dryRun: false, }, npPackageResult); t.deepEqual(execaStub.firstCall.args, ['npm', ['ci', '--engine-strict'], {cwd: npPackageResult.rootDirectory}]); @@ -427,7 +427,7 @@ test('contents mode looks up lockfile in projectDirectory and installs there', a tests: false, publish: false, runPublish: false, - preview: false, + dryRun: false, }, {package_: npPackageResult.package_, projectDirectory, rootDirectory}); t.true(findLockfileStub.calledOnceWithExactly(projectDirectory, packageManager)); @@ -474,7 +474,7 @@ test('contents mode keeps cleanup, install, and tests in projectDirectory while t.is(publishCwd, rootDirectory); }); -test('preview with no-cleanup does not execute install command', async t => { +test('dryRun with no-cleanup does not execute install command', async t => { const execaStub = sinon.stub().returns(fakeExecaReturn()); const verifyWorkingTreeIsCleanStub = sinon.stub(); @@ -501,14 +501,46 @@ test('preview with no-cleanup does not execute install command', async t => { tests: false, publish: false, runPublish: false, - preview: true, + dryRun: true, }, npPackageResult); t.true(execaStub.notCalled); t.true(verifyWorkingTreeIsCleanStub.notCalled); }); -test('preview without lockfile does not clean up or run tests', async t => { +test('dryRun without lockfile does not clean up or run tests', async t => { + const deleteAsyncStub = sinon.stub(); + const execaStub = sinon.stub().returns(fakeExecaReturn()); + + /** @type {typeof np} */ + const npMock = await esmock('../source/index.js', { + del: {deleteAsync: deleteAsyncStub}, + execa: {execa: execaStub}, + '../source/prerequisite-tasks.js': sinon.stub(), + '../source/git-tasks.js': sinon.stub(), + '../source/git-util.js': { + hasUpstream: sinon.stub().returns(true), + pushGraceful: sinon.stub(), + verifyWorkingTreeIsClean: sinon.stub(), + }, + '../source/package-manager/index.js': { + ...await import('../source/package-manager/index.js'), + findLockfile: sinon.stub().returns(undefined), + }, + }); + + await npMock('1.0.0', { + ...defaultOptions, + dryRun: true, + publish: false, + runPublish: false, + }, npPackageResult); + + t.true(deleteAsyncStub.notCalled); + t.true(execaStub.notCalled); +}); + +test('preview option remains a supported alias for dryRun', async t => { const deleteAsyncStub = sinon.stub(); const execaStub = sinon.stub().returns(fakeExecaReturn()); From 86587086e2abc790c1c85df1c56f5638c23a21f3 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 17 Apr 2026 23:16:33 +0700 Subject: [PATCH 174/180] Run Git preflight before prompting (#788) --- source/cli-implementation.js | 6 +++ source/git-tasks.js | 27 +++++++++- source/git-util.js | 6 +++ test/cli.js | 100 +++++++++++++++++++++++++++++++++++ test/tasks/git-tasks.js | 100 +++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 2 deletions(-) diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 71731c70..2df6d6e1 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -11,6 +11,7 @@ import config from './config.js'; import * as util from './util.js'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; +import {verifyGitTasks} from './git-tasks.js'; import {getOidcProvider} from './npm/oidc.js'; import {SEMVER_INCREMENTS} from './version.js'; import ui from './ui.js'; @@ -177,6 +178,11 @@ async function getOptions() { const version = flags.releaseDraftOnly ? package_.version : cli.input.at(0); const branch = flags.branch ?? await git.defaultBranch(); + if (!flags.releaseDraftOnly) { + // Keep obvious Git failures ahead of the wizard, but do not replace the later Git task. + // The publish flow still needs a final check in case the repo changes while the user is prompting or logging in. + await verifyGitTasks({anyBranch: flags.anyBranch, branch, remote: flags.remote}); + } const options = await ui({ ...flags, diff --git a/source/git-tasks.js b/source/git-tasks.js index 8106685a..4e426136 100644 --- a/source/git-tasks.js +++ b/source/git-tasks.js @@ -1,7 +1,7 @@ import Listr from 'listr'; import * as git from './git-util.js'; -const gitTasks = options => { +const createGitTasks = options => { const tasks = [ { title: 'Check current branch', @@ -21,7 +21,30 @@ const gitTasks = options => { tasks.shift(); } - return new Listr(tasks); + return tasks; }; +export const verifyGitTasks = async options => { + if (!options.anyBranch) { + await git.verifyCurrentBranchIsReleaseBranch(options.branch); + } + + await git.verifyWorkingTreeIsClean(); + if (options.remote) { + await git.verifyRemoteIsValid(options.remote); + } else if ( + !( + options.anyBranch + && await git.isHeadDetached() + ) + && await git.hasUpstream() + ) { + await git.verifyRemoteIsValid(await git.getUpstreamRemote()); + } + + await git.verifyRemoteHistoryIsClean(); +}; + +const gitTasks = options => new Listr(createGitTasks(options)); + export default gitTasks; diff --git a/source/git-util.js b/source/git-util.js index af7a14eb..f69d3978 100644 --- a/source/git-util.js +++ b/source/git-util.js @@ -115,6 +115,12 @@ export const getCurrentBranch = async () => { return stdout; }; +export const getUpstreamRemote = async () => { + const currentBranch = await getCurrentBranch(); + const {stdout} = await execa('git', ['config', `branch.${currentBranch}.remote`]); + return stdout; +}; + export const verifyCurrentBranchIsReleaseBranch = async releaseBranch => { const currentBranch = await getCurrentBranch(); if (currentBranch !== releaseBranch) { diff --git a/test/cli.js b/test/cli.js index fb99705b..04042425 100644 --- a/test/cli.js +++ b/test/cli.js @@ -1,5 +1,8 @@ import path from 'node:path'; +import process from 'node:process'; import test from 'ava'; +import sinon from 'sinon'; +import esmock from 'esmock'; import {execa} from 'execa'; import {npPackage, npRootDirectory as rootDirectory} from '../source/util.js'; import {cliPasses} from './_helpers/verify-cli.js'; @@ -69,3 +72,100 @@ test('flags: --preview remains an alias for --dry-run', async t => { t.true(stderr.includes('--wat')); t.false(stderr.includes('--preview')); }); + +const loadCliImplementation = async overrides => esmock('../source/cli-implementation.js', {}, { + meow: { + default: sinon.stub().returns({ + input: ['patch'], + flags: { + publish: false, + }, + pkg: npPackage, + }), + }, + 'update-notifier': {default: sinon.stub().returns({notify: sinon.stub()})}, + '../source/config.js': {default: sinon.stub().resolves({})}, + '../source/util.js': { + readPackage: sinon.stub().resolves({ + package_: { + name: 'test-package', + version: '1.0.0', + }, + rootDirectory: process.cwd(), + }), + }, + '../source/git-util.js': { + defaultBranch: sinon.stub().resolves('main'), + }, + '../source/git-tasks.js': { + verifyGitTasks: sinon.stub().resolves(), + }, + '../source/package-manager/index.js': { + getPackageManagerConfig: sinon.stub().returns({ + id: 'npm', + cli: 'npm', + }), + }, + '../source/npm/util.js': { + isExternalRegistry: sinon.stub().returns(false), + isPackageNameAvailable: sinon.stub(), + username: sinon.stub(), + login: sinon.stub(), + }, + '../source/npm/oidc.js': { + getOidcProvider: sinon.stub().returns(undefined), + }, + '../source/ui.js': {default: sinon.stub().callsFake(async options => ({...options, confirm: false, version: '1.0.1'}))}, + '../source/index.js': {default: sinon.stub().resolves({name: 'test-package', version: '1.0.1'})}, + 'exit-hook': { + gracefulExit: sinon.stub(), + }, + ...overrides, +}); + +test.serial('cli runs git preflight before prompting', async t => { + const verifyGitTasksStub = sinon.stub().rejects(new Error('Not on `main` branch.')); + const uiStub = sinon.stub().callsFake(async options => ({...options, confirm: true, version: '1.0.1'})); + const gracefulExitStub = sinon.stub(); + const consoleErrorStub = sinon.stub(console, 'error'); + + await loadCliImplementation({ + '../source/git-tasks.js': { + verifyGitTasks: verifyGitTasksStub, + }, + '../source/ui.js': {default: uiStub}, + 'exit-hook': { + gracefulExit: gracefulExitStub, + }, + }); + + t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined})); + t.true(uiStub.notCalled); + t.true(gracefulExitStub.calledOnceWithExactly(1)); + + consoleErrorStub.restore(); +}); + +test.serial('cli continues to the publish flow after successful git preflight', async t => { + const verifyGitTasksStub = sinon.stub().resolves(); + const uiStub = sinon.stub().callsFake(async options => ({...options, confirm: true, version: '1.0.1'})); + const npStub = sinon.stub().resolves({name: 'test-package', version: '1.0.1'}); + const gracefulExitStub = sinon.stub(); + + await loadCliImplementation({ + '../source/git-tasks.js': { + verifyGitTasks: verifyGitTasksStub, + }, + '../source/ui.js': {default: uiStub}, + '../source/index.js': {default: npStub}, + 'exit-hook': { + gracefulExit: gracefulExitStub, + }, + }); + + t.true(verifyGitTasksStub.calledOnceWithExactly({anyBranch: undefined, branch: 'main', remote: undefined})); + t.true(uiStub.calledOnce); + t.true(npStub.calledOnce); + t.false('skipGitTasks' in npStub.firstCall.args[1]); + t.true(gracefulExitStub.notCalled); +}); diff --git a/test/tasks/git-tasks.js b/test/tasks/git-tasks.js index 6fbecd27..1990a363 100644 --- a/test/tasks/git-tasks.js +++ b/test/tasks/git-tasks.js @@ -176,3 +176,103 @@ test.serial('checks should pass when publishing from master, working tree is cle ], async ({t, testedModule: gitTasks}) => { await t.notThrowsAsync(run(gitTasks({branch: 'master'}))); }); + +test.serial('preflight should validate remote before checking remote history', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'master', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git status --short --branch --porcelain', + stdout: '## master...origin/master', + }, + { + command: 'git config branch.master.remote', + stdout: 'origin', + }, + { + command: 'git ls-remote origin HEAD', + exitCode: 1, + stderr: 'fatal: could not read from remote repository', + }, +], async ({t, testedModule}) => { + await t.throwsAsync( + testedModule.verifyGitTasks({branch: 'master'}), + {message: 'Git fatal error: could not read from remote repository'}, + ); +}); + +test.serial('preflight should skip upstream probe on detached head with anyBranch', createFixture, [ + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git symbolic-ref --quiet HEAD', + exitCode: 1, + }, + { + command: 'git rev-parse @{u}', + stderr: 'fatal: no upstream configured for HEAD', + }, +], async ({t, testedModule}) => { + await t.notThrowsAsync(testedModule.verifyGitTasks({anyBranch: true})); +}); + +test.serial('preflight should validate explicit remote without upstream', createFixture, [ + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git ls-remote upstream HEAD', + exitCode: 1, + stderr: 'fatal: remote upstream not found', + }, +], async ({t, testedModule}) => { + await t.throwsAsync( + testedModule.verifyGitTasks({anyBranch: true, remote: 'upstream'}), + {message: 'Git fatal error: remote upstream not found'}, + ); +}); + +test.serial('preflight should validate the tracked remote instead of origin', createFixture, [ + { + command: 'git symbolic-ref --short HEAD', + stdout: 'main', + }, + { + command: 'git status --porcelain', + stdout: '', + }, + { + command: 'git status --short --branch --porcelain', + stdout: '## main...upstream/main', + }, + { + command: 'git config branch.main.remote', + stdout: 'upstream', + }, + { + command: 'git ls-remote upstream HEAD', + exitCode: 0, + }, + { + command: 'git rev-parse @{u}', + exitCode: 0, + }, + { + command: 'git fetch --dry-run', + exitCode: 0, + }, + { + command: 'git rev-list --count --left-only @{u}...HEAD', + stdout: '0', + }, +], async ({t, testedModule}) => { + await t.notThrowsAsync(testedModule.verifyGitTasks({branch: 'main'})); +}); From e88eea10bf44379769646473360087d761f8a73f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 17 Apr 2026 23:31:49 +0700 Subject: [PATCH 175/180] Update dependencies --- package.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index c2dd1ed1..040857f7 100644 --- a/package.json +++ b/package.json @@ -35,18 +35,18 @@ "dependencies": { "chalk": "^5.6.2", "chalk-template": "^1.1.2", - "clipboardy": "^5.0.2", - "cosmiconfig": "^9.0.0", + "clipboardy": "^5.3.1", + "cosmiconfig": "^9.0.1", "del": "^8.0.1", "escape-goat": "^4.0.0", "escape-string-regexp": "^5.0.0", "execa": "^9.6.1", - "exit-hook": "^5.0.1", + "exit-hook": "^5.1.0", "github-url-from-git": "^1.5.0", "hosted-git-info": "^9.0.2", "ignore-walk": "^8.0.0", "import-local": "^3.2.0", - "inquirer": "^13.2.0", + "inquirer": "^13.4.1", "is-installed-globally": "^1.0.0", "is-interactive": "^2.0.0", "is-scoped": "^3.0.0", @@ -54,18 +54,18 @@ "listr": "^0.14.3", "listr-input": "^0.2.1", "log-symbols": "^7.0.1", - "meow": "^14.0.0", + "meow": "^14.1.0", "new-github-release-url": "^2.0.0", - "npm-name": "^8.0.0", + "npm-name": "^8.1.0", "onetime": "^7.0.0", "open": "^11.0.0", "p-memoize": "^8.0.0", - "package-directory": "^8.0.0", + "package-directory": "^8.2.0", "path-exists": "^5.0.0", "read-package-up": "^12.0.0", - "read-pkg": "^10.0.0", + "read-pkg": "^10.1.0", "rxjs": "^7.8.2", - "semver": "^7.7.3", + "semver": "^7.7.4", "symbol-observable": "^4.0.0", "terminal-link": "^5.0.0", "update-notifier": "^7.3.1" @@ -76,11 +76,11 @@ "ava": "^6.4.1", "common-tags": "^1.8.2", "esmock": "^2.7.3", - "fs-extra": "^11.3.3", + "fs-extra": "^11.3.4", "map-obj": "^6.0.0", - "sinon": "^21.0.1", - "strip-ansi": "^7.1.2", - "tempy": "^3.1.0", + "sinon": "^21.1.2", + "strip-ansi": "^7.2.0", + "tempy": "^3.2.0", "write-package": "^7.2.0", "xo": "^1.2.3" }, From 1aa3bf5ae4debc6d00dc8600b126102686d4cbf9 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Fri, 17 Apr 2026 23:35:57 +0700 Subject: [PATCH 176/180] 11.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 040857f7..19a4819a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "11.0.3", + "version": "11.1.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From 03d47873d941acf0ab853530112a98235849762a Mon Sep 17 00:00:00 2001 From: LitoMore Date: Mon, 20 Apr 2026 20:08:39 +0800 Subject: [PATCH 177/180] Add `devEngines.packageManager` support (#789) --- readme.md | 6 +- source/cli-implementation.js | 2 +- source/package-manager/index.js | 35 ++++++- test/cli.js | 2 +- test/package-manager.js | 175 ++++++++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index e2950e76..3c0a7a74 100644 --- a/readme.md +++ b/readme.md @@ -71,7 +71,7 @@ $ np --help --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) - --package-manager Use a specific package manager (default: 'packageManager' field in package.json) + --package-manager Use a specific package manager (default: package.json packageManager/devEngines) --provenance Publish with npm provenance statements (CI-only) --remote Git remote to push to (default: origin) @@ -109,7 +109,7 @@ Currently, these are the flags you can configure: - `testScript` - Name of npm run script to run tests before publishing (`test` by default). - `2fa` - Enable 2FA on new packages (`true` by default) (setting this to `false` is not recommended). - `message` - The commit message used for the version bump. Any `%s` in the string will be replaced with the new version. By default, npm uses `%s` and Yarn uses `v%s`. -- `packageManager` - Set the package manager to be used. Defaults to the [packageManager field in package.json](https://nodejs.org/dist/latest-v16.x/docs/api/all.html#all_packages_packagemanager), so only use if you can't update package.json for some reason. +- `packageManager` - Set the package manager to be used. Defaults to the [`packageManager`](https://nodejs.org/api/packages.html#packagemanager) or [`devEngines.packageManager`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines) field in package.json, so only use if you can't update package.json for some reason. - `provenance` - Publish with [npm provenance statements](https://docs.npmjs.com/generating-provenance-statements) (`false` by default). Requires npm 9.5.0+ and a supported CI environment (GitHub Actions or GitLab CI/CD). - `remote` - Git remote to push tags and commits to. Useful when publishing from a fork where `origin` is your fork and `upstream` is the main repository. @@ -263,7 +263,7 @@ Set the [`registry` option](https://docs.npmjs.com/misc/config#registry) in pack ### Package managers -If a package manager is not set in package.json, via configuration (`packageManager`), or via the CLI (`--package-manager`), `np` will attempt to infer the best package manager to use by looking for lockfiles. But it's recommended to set the [`packageManager` field](https://nodejs.org/api/packages.html#packagemanager) in your package.json to be consistent with other tools. See also the [corepack docs](https://nodejs.org/api/corepack.html). +If a package manager is not set in package.json (`packageManager` or `devEngines.packageManager`), via configuration (`packageManager`), or via the CLI (`--package-manager`), `np` will attempt to infer the best package manager to use by looking for lockfiles. But it's recommended to set the [`packageManager` field](https://nodejs.org/api/packages.html#packagemanager) in your package.json to be consistent with other tools. See also the [corepack docs](https://nodejs.org/api/corepack.html). ### Publish with a CI diff --git a/source/cli-implementation.js b/source/cli-implementation.js index 2df6d6e1..829b0cbf 100755 --- a/source/cli-implementation.js +++ b/source/cli-implementation.js @@ -42,7 +42,7 @@ const cli = meow(` --test-script Name of npm run script to run tests before publishing (default: test) --no-2fa Don't enable 2FA on new packages (not recommended) --message Version bump commit message, '%s' will be replaced with version (default: '%s' with npm and 'v%s' with yarn) - --package-manager Use a specific package manager (default: 'packageManager' field in package.json) + --package-manager Use a specific package manager (default: package.json packageManager/devEngines) --provenance Publish with npm provenance statements (CI-only) --remote Git remote to push to (default: origin) diff --git a/source/package-manager/index.js b/source/package-manager/index.js index a9d1b7d4..5b8a9603 100644 --- a/source/package-manager/index.js +++ b/source/package-manager/index.js @@ -18,7 +18,7 @@ export function findLockfile(rootDirectory, config) { @param {import('read-pkg').NormalizedPackageJson} package_ */ export function getPackageManagerConfig(rootDirectory, package_) { - const config = configFromPackageManagerField(package_); + const config = configFromPackageManagerField(package_) ?? configFromDevEnginesPackageManager(package_); return config || configFromLockfile(rootDirectory) || configs.npmConfig; } @@ -30,7 +30,36 @@ function configFromPackageManagerField(package_) { const [packageManager, version] = package_.packageManager.split('@'); - if (packageManager === 'yarn' && version && semver.gte(version, '2.0.0')) { + return configFromPackageManager(packageManager, version, package_.packageManager); +} + +/** @param {import('read-pkg').NormalizedPackageJson} package_ */ +function configFromDevEnginesPackageManager(package_) { + const {packageManager} = package_.devEngines ?? {}; + if (packageManager === undefined) { + return undefined; + } + + const packageManagers = Array.isArray(packageManager) ? packageManager : [packageManager]; + if (packageManagers.length === 0) { + throw new Error('Missing "name" property for "packageManager".'); + } + + for (const packageManager of packageManagers) { + if (!packageManager || typeof packageManager !== 'object' || !('name' in packageManager) || typeof packageManager.name !== 'string') { + throw new Error('Missing "name" property for "packageManager".'); + } + } + + const [packageManager_] = packageManagers; + const version = typeof packageManager_.version === 'string' ? packageManager_.version : undefined; + return configFromPackageManager(packageManager_.name, version, packageManager_.name); +} + +function configFromPackageManager(packageManager, version, rawPackageManager) { + const minimumVersion = version && semver.minVersion(version); + + if (packageManager === 'yarn' && minimumVersion && semver.gte(minimumVersion, '2.0.0')) { return configs.yarnBerryConfig; } @@ -50,7 +79,7 @@ function configFromPackageManagerField(package_) { return configs.bunConfig; } - throw new Error(`Invalid package manager: ${package_.packageManager}`); + throw new Error(`Invalid package manager: ${rawPackageManager}`); } /** @param {string} rootDirectory */ diff --git a/test/cli.js b/test/cli.js index 04042425..1893e66d 100644 --- a/test/cli.js +++ b/test/cli.js @@ -35,7 +35,7 @@ test('flags: --help', cliPasses, cli, '--help', [ '--test-script Name of npm run script to run tests before publishing (default: test)', '--no-2fa Don\'t enable 2FA on new packages (not recommended)', '--message Version bump commit message, \'%s\' will be replaced with version (default: \'%s\' with npm and \'v%s\' with yarn)', - '--package-manager Use a specific package manager (default: \'packageManager\' field in package.json)', + '--package-manager Use a specific package manager (default: package.json packageManager/devEngines)', '--provenance Publish with npm provenance statements (CI-only)', '--remote Git remote to push to (default: origin)', '', diff --git a/test/package-manager.js b/test/package-manager.js index a4b82229..b7e295c7 100644 --- a/test/package-manager.js +++ b/test/package-manager.js @@ -8,6 +8,7 @@ import { yarnConfig, yarnBerryConfig, pnpmConfig, + bunConfig, } from '../source/package-manager/configs.js'; test('detects npm from package-lock.json', t => { @@ -76,3 +77,177 @@ test('detects Yarn Classic from packageManager field', t => { t.is(config, yarnConfig); fs.rmSync(temporaryDirectory, {recursive: true}); }); + +test('detects pnpm from devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'pnpm', + version: '>=9', + }, + }, + }); + + t.is(config, pnpmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Bun from devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'bun', + version: '^1.0.0', + }, + }, + }); + + t.is(config, bunConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Berry from devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'yarn', + version: '^4.0.0', + }, + }, + }); + + t.is(config, yarnBerryConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects Yarn Classic from devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'yarn', + version: '^1.22.0', + }, + }, + }); + + t.is(config, yarnConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('detects package manager from devEngines.packageManager array', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: [ + { + name: 'pnpm', + version: '>=9', + }, + { + name: 'npm', + onFail: 'warn', + }, + ], + }, + }); + + t.is(config, pnpmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('packageManager field takes precedence over devEngines.packageManager field', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + const config = getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + packageManager: 'npm@10.0.0', + devEngines: { + packageManager: { + name: 'pnpm', + }, + }, + }); + + t.is(config, npmConfig); + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('throws when devEngines.packageManager has no name', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + fs.writeFileSync(path.join(temporaryDirectory, 'pnpm-lock.yaml'), ''); + + t.throws(() => getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + version: '>=9', + }, + }, + }), { + message: 'Missing "name" property for "packageManager".', + }); + + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('throws when devEngines.packageManager array entry has no name', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + t.throws(() => getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: [ + { + version: '>=9', + }, + { + name: 'pnpm', + }, + ], + }, + }), { + message: 'Missing "name" property for "packageManager".', + }); + + fs.rmSync(temporaryDirectory, {recursive: true}); +}); + +test('throws for invalid devEngines.packageManager name', t => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-')); + + t.throws(() => getPackageManagerConfig(temporaryDirectory, { + name: 'test', + version: '1.0.0', + devEngines: { + packageManager: { + name: 'deno', + }, + }, + }), { + message: 'Invalid package manager: deno', + }); + + fs.rmSync(temporaryDirectory, {recursive: true}); +}); From 055dbf139d23c5d7269115141d80e67b6bb64839 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 20 Apr 2026 19:11:16 +0700 Subject: [PATCH 178/180] 11.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 19a4819a..bececf4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "11.1.0", + "version": "11.2.0", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np", From abfe30bdaa5b3c73594b2625a4c496855c857c0b Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 12 May 2026 23:48:18 +0700 Subject: [PATCH 179/180] Skip Node.js engine check for prerelease-to-prerelease bumps Fixes #791 --- source/prerequisite-tasks.js | 2 +- test/tasks/prerequisite-tasks.js | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/source/prerequisite-tasks.js b/source/prerequisite-tasks.js index 491b2c20..e2631722 100644 --- a/source/prerequisite-tasks.js +++ b/source/prerequisite-tasks.js @@ -109,7 +109,7 @@ const prerequisiteTasks = (input, package_, options, {packageManager, rootDirect // Only major and premajor releases are allowed to drop Node.js support // For pre-1.0.0 packages, minor bumps are considered breaking changes per semver - if (diff !== 'major' && diff !== 'premajor' && semver.major(package_.version) >= 1) { + if (diff !== 'major' && diff !== 'premajor' && diff !== 'prerelease' && semver.major(package_.version) >= 1) { throw new Error(`Raising minimum Node.js version from ${publishedMinimum} to ${localMinimum} requires a major version bump. The current release is a ${diff} bump.`); } } diff --git a/test/tasks/prerequisite-tasks.js b/test/tasks/prerequisite-tasks.js index 035e9d83..5684a017 100644 --- a/test/tasks/prerequisite-tasks.js +++ b/test/tasks/prerequisite-tasks.js @@ -601,7 +601,7 @@ test.serial('should fail when dropping Node.js support in a prepatch release', c assertTaskFailed(t, 'Check for Node.js engine support drop'); }); -test.serial('should fail when dropping Node.js support in a prerelease release', createFixture, [{ +test.serial('should not fail when dropping Node.js support in a prerelease release', createFixture, [{ command: 'npm view --json test engines', stdout: JSON.stringify({node: '>=16'}), }, { @@ -614,12 +614,7 @@ test.serial('should fail when dropping Node.js support in a prerelease release', command: 'git rev-parse --quiet --verify refs/tags/v1.0.0-1', stdout: '', }], async ({t, testedModule: prerequisiteTasks}) => { - await t.throwsAsync( - run(prerequisiteTasks('1.0.0-1', {name: 'test', version: '1.0.0-0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig})), - {message: 'Raising minimum Node.js version from 16.0.0 to 18.0.0 requires a major version bump. The current release is a prerelease bump.'}, - ); - - assertTaskFailed(t, 'Check for Node.js engine support drop'); + await t.notThrowsAsync(run(prerequisiteTasks('1.0.0-1', {name: 'test', version: '1.0.0-0', engines: {node: '>=18'}}, {tag: 'next'}, {packageManager: npmConfig}))); }); test.serial('yolo mode: should disable task checking for Node.js engine support drop', createFixture, [{ From 18096016854e9d1cd310f5e858e4190779d1f42f Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Wed, 13 May 2026 00:03:49 +0700 Subject: [PATCH 180/180] 11.2.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bececf4e..9914753e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "np", - "version": "11.2.0", + "version": "11.2.1", "description": "A better `npm publish`", "license": "MIT", "repository": "sindresorhus/np",