UNPKG

np

Version:
351 lines (309 loc) • 11.6 kB
// Important: This must be the first import. It defines `Symbol.observable` before any copy of rxjs loads, so np's rxjs and the rxjs 6 inside the lazily loaded `listr-input` agree on the symbol. Without it, `listr` polyfills the symbol at run time, after np's rxjs has loaded, and the OTP prompt then fails with “You provided an invalid object where a stream was expected”. // eslint-disable-next-line import-x/no-unassigned-import, import-x/order import 'symbol-observable'; import path from 'node:path'; import process from 'node:process'; 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, catchError, filter, finalize, from, mergeMap, throwError, } 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 {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'; import * as git from './git-util.js'; import * as npm from './npm/util.js'; const restoreCursor = () => { if (process.stderr.isTTY) { process.stderr.write('\u{1B}[?25h'); } }; /** Executes a command and returns an observable stream of its output. @type {(cmd: string, args: string[], options?: import('execa').Options) => import('rxjs').Observable<unknown>} */ 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), catchError(error => { // Include stderr in error message for better diagnostics if (error.stderr) { error.message = `${error.shortMessage}\n${error.stderr}`; } throw error; }), ); }; /** Runs the np publish workflow. @param {string} input - The version bump type or explicit version. @param {import('./cli-implementation.js').Options} options - Configuration options for the publish workflow. @param {{package_: import('read-pkg').NormalizedPackageJson; projectDirectory?: string; rootDirectory: string}} context - Project context including package data and directories. */ export default async function np(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; } const runTests = options.tests && !options.yolo; const runCleanup = options.cleanup && !options.yolo; const isRunInstall = !options.yolo; const lockfile = findLockfile(projectDirectory, packageManager); const isOnGitHub = options.repoUrl && hostedGitInfo.fromUrl(options.repoUrl)?.type === 'github'; if (options.releaseDraftOnly) { await releaseTaskHelper(options, package_, packageManager); return package_; } const testScript = options.testScript || 'test'; let publishStatus = 'UNKNOWN'; let pushedObjects; const rollback = onetime(async () => { console.log('\nPublish failed. Rolling back to the previous state…'); const tagVersionPrefix = await util.getTagVersionPrefix(packageManager); const latestTag = await git.latestTag(); const versionInLatestTag = latestTag.slice(tagVersionPrefix.length); async function getPackageVersion() { const {package_: currentPackage} = await util.readPackage(rootDirectory); return currentPackage.version; } try { // Verify that the package's version has been bumped before deleting the last tag and commit. if (versionInLatestTag === await getPackageVersion() && versionInLatestTag !== package_.version) { await git.deleteTag(latestTag); await git.removeLastCommit(); } console.log('Successfully rolled back the project to its previous state.'); } catch (error) { console.log(`Couldn't roll back because of the following error:\n${error}`); } }); asyncExitHook(async () => { if (options.dryRun || publishStatus === 'SUCCESS') { return; } if (publishStatus === 'FAILED') { await rollback(); } else { console.log('\nAborted!'); } }, {wait: 2000}); // 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'}}; /** Returns the command array used to publish the package. @param {typeof options} _options - Options used to determine publish arguments. */ function getPublishCommand(_options) { const publishCommand = packageManager.publishCommand || (arguments_ => [packageManager.cli, arguments_]); const arguments_ = getPackagePublishArguments(_options); 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', enabled: () => options.runPublish, task: () => prerequisiteTasks(input, package_, options, {packageManager, rootDirectory}), }, { title: 'Git', task: () => gitTasks(options), }, { title: 'Cleanup', enabled: () => runCleanup && !lockfile, skip() { if (options.dryRun) { return '[Dry run] Command not executed: delete node_modules.'; } }, task: () => deleteAsync(path.join(projectDirectory, 'node_modules')), }, { title: `Installing dependencies using ${packageManager.id}`, enabled: () => isRunInstall, skip() { if (options.dryRun) { return `[Dry run] Command not executed: ${printCommand(getInstallCommand())}.`; } }, task: () => new Listr([ { title: 'Running install command', task() { return exec(...getInstallCommand(), {cwd: projectDirectory}); }, }, { title: 'Checking working tree is still clean', // If lockfile was out of date and tracked by git, this will fail task: () => git.verifyWorkingTreeIsClean(), }, ]), }, { title: 'Running tests', enabled: () => runTests, skip() { if (options.dryRun) { return `[Dry run] Command not executed: ${packageManager.cli} run ${testScript}.`; } }, task: () => exec(packageManager.cli, ['run', testScript], {...ciEnvOptions, cwd: projectDirectory}), }, { title: 'Bumping version', skip() { if (!options.dryRun) { return; } const [cli, arguments_] = packageManager.versionCommand(input); if (options.message) { arguments_.push('--message', options.message.replaceAll('%s', () => input)); } return `[Dry run] Command not executed: ${printCommand([cli, arguments_])}`; }, task() { const [cli, arguments_] = packageManager.versionCommand(input); if (options.message) { arguments_.push('--message', options.message); } // Inherit stdin to allow GPG password prompts for commit signing return exec(cli, arguments_, {stdin: 'inherit'}); }, }, ...options.runPublish ? [ { title: options.stage ? 'Staging package' : 'Publishing package', skip() { if (!options.dryRun) { return; } const command = getPublishCommand(options); return `[Dry run] Command not executed: ${printCommand(command)}.`; }, /** Runs the npm publish step and handles OTP prompts and errors. @type {(context: object, task: object) => import('rxjs').Observable<unknown>} */ task(context, task) { let hasError = false; return runPublish(getPublishCommand(options), {cwd: rootDirectory}) .pipe(catchError(error => handleNpmError(error, task, otp => { context.otp = otp; return runPublish(getPublishCommand({...options, otp}), {cwd: rootDirectory}); }))) .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.dryRun) { return; } const arguments_ = await getEnable2faArguments(package_.name, options); return `[Dry run] Command not executed: npm ${arguments_.join(' ')}.`; }, task: (context, task) => enable2fa(task, package_.name, {otp: context.otp}), }] : [], ] : [], { title: 'Pushing tags', async skip() { if (!options.remote && !(await git.hasUpstream())) { return 'Upstream branch not found; not pushing.'; } if (options.dryRun) { const remote = options.remote ? `${options.remote} ` : ''; return `[Dry run] Command not executed: git push ${remote}--follow-tags.`; } if (publishStatus === 'FAILED' && options.runPublish) { return 'Couldn\'t publish package to npm; not pushing.'; } }, async task() { pushedObjects = await git.pushGraceful(isOnGitHub, options.remote); }, }, ...options.releaseDraft ? [{ title: 'Creating release draft on GitHub', enabled: () => isOnGitHub === true, skip() { if (options.dryRun) { return '[Dry run] GitHub Releases draft will not be opened in dry-run mode.'; } }, task: () => releaseTaskHelper(options, package_, packageManager), }] : [], ], { showSubtasks: false, renderer: options.renderer ?? 'default', clearOutput: !options.dryRun && !options.releaseDraftOnly, }); if (!options.runPublish) { publishStatus = 'SUCCESS'; } await tasks.run(); // Listr-update-renderer calls logUpdate.clear() on successful clearOutput, // which skips logUpdate.done() and can leave the terminal cursor hidden. restoreCursor(); if (pushedObjects) { console.error(`\n${logSymbols.error} ${pushedObjects.reason}`); } const {package_: newPackage} = await util.readPackage(); return newPackage; }