np
Version:
A better `npm publish`
396 lines (325 loc) • 12.4 kB
JavaScript
import path from 'node:path';
import {pathExists} from 'path-exists';
import {execa} from 'execa';
import npmName from 'npm-name';
import pMemoize from 'p-memoize';
import chalk from 'chalk-template';
import * as util from '../util.js';
export const version = async () => {
const {stdout} = await execa('npm', ['--version']);
return stdout;
};
export const npmNetworkTimeout = 15_000; // 15 seconds for npm registry calls
const throwIfNpmTimeout = error => {
if (!error.timedOut) {
return;
}
error.message = 'Connection to npm registry timed out';
throw error;
};
// Unwrap the array that npm 12 wraps `npm view --json` output in; older npm returns the object directly.
const parseNpmViewOutput = stdout => {
const parsed = JSON.parse(stdout);
return Array.isArray(parsed) ? parsed[0] : parsed;
};
export const checkConnection = async () => {
try {
await execa('npm', ['ping'], {timeout: npmNetworkTimeout});
return true;
} catch (error) {
throwIfNpmTimeout(error);
throw new Error('Connection to npm registry failed', {cause: error});
}
};
// Memoized because the early auth check in the CLI and the prerequisite task both need it, and `npm whoami` is a network round trip. Failures are not cached, so re-verifying after `npm login` still runs the command.
export const username = pMemoize(async ({externalRegistry}) => {
const arguments_ = ['whoami'];
if (externalRegistry) {
arguments_.push('--registry', externalRegistry);
}
try {
const {stdout} = await execa('npm', arguments_, {timeout: npmNetworkTimeout});
return stdout;
} catch (error) {
throwIfNpmTimeout(error);
const isNotLoggedIn = /ENEEDAUTH|E401/v.test(error.stderr);
const message = isNotLoggedIn
? 'You must be logged in. Use `npm login` and try again.'
: 'Authentication error. Use `npm whoami` to troubleshoot.';
const authError = new Error(message);
authError.isNotLoggedIn = isNotLoggedIn;
throw authError;
}
}, {cacheKey: ([{externalRegistry}]) => externalRegistry});
class ExitPromptError extends Error {
name = 'ExitPromptError';
}
export const login = async ({externalRegistry}) => {
const arguments_ = ['login'];
if (externalRegistry) {
arguments_.push('--registry', externalRegistry);
}
try {
await execa('npm', arguments_, {
stdin: 'inherit',
stdout: 'inherit',
stderr: 'pipe',
});
} catch (error) {
// User canceled the login prompt
if (error.stderr?.includes('canceled')) {
throw new ExitPromptError('Login canceled', {cause: error});
}
throw error;
}
};
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;
if (typeof registry !== 'string') {
return false;
}
const normalizedRegistry = registry.trim();
const httpsVariant = normalizedRegistry.replace(/^http:\/\//v, 'https://');
return !NPM_DEFAULT_REGISTRIES.has(normalizedRegistry)
&& !NPM_DEFAULT_REGISTRIES.has(httpsVariant);
};
export const collaborators = async package_ => {
const packageName = package_.name;
util.assert(typeof packageName === 'string', 'Package name is required');
const arguments_ = ['access', 'list', 'collaborators', packageName, '--json'];
if (package_.publishConfig?.registry) {
arguments_.push('--registry', package_.publishConfig.registry);
}
try {
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;
}
// 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;
}
};
export const prereleaseTags = async packageName => {
util.assert(typeof packageName === 'string', 'Package name is required');
let tags = [];
try {
const {stdout} = await execa('npm', ['view', '--json', packageName, 'dist-tags'], {timeout: npmNetworkTimeout});
const distTags = parseNpmViewOutput(stdout);
tags = Object.keys(distTags ?? {})
.filter(tag => tag !== 'latest');
} catch (error) {
throwIfNpmTimeout(error);
// HACK: NPM is mixing JSON with plain text errors. npm 12 writes the JSON
// error body to stdout, while older npm mixes it into stderr where every
// text line starts with 'npm ERR!' (npm <10) or 'npm error' (npm >=10),
// so we prefer stdout and fall back to stripping those prefixed lines.
/** @type {string} */
const errorJSON = error.stdout?.trim()
? error.stdout
: (error.stderr ?? '')
.split('\n')
.filter(line => !line.startsWith('npm ERR!') && !line.startsWith('npm error'))
.join('\n');
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;
}
}
if (tags.length === 0) {
tags.push('next');
}
return tags;
};
export const isPackageNameAvailable = async package_ => {
const arguments_ = [package_.name];
const availability = {
isAvailable: false,
isUnknown: false,
};
if (isExternalRegistry(package_)) {
arguments_.push({
registryUrl: package_.publishConfig.registry,
});
}
try {
availability.isAvailable = await npmName(...arguments_) || false;
} catch {
availability.isUnknown = true;
}
return availability;
};
export const verifyRecentNpmVersion = async () => {
const npmVersion = await version();
util.validateEngineVersionSatisfies('npm', npmVersion);
};
export const checkIgnoreStrategy = async ({files}, rootDirectory) => {
const npmignoreExistsInPackageRootDirectory = await pathExists(path.resolve(rootDirectory, '.npmignore'));
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 isIgnoreScriptsEnabled = async (rootDirectory, packageManagerCli = 'npm') => {
try {
const {stdout} = await execa(packageManagerCli, ['config', 'get', 'ignore-scripts'], {cwd: rootDirectory});
return stdout.trim() === 'true';
} catch {
// If we can't determine the setting, assume scripts run so we don't block a valid publish.
return false;
}
};
// Memoized because it is needed both when listing new files before the prompts and when verifying entry points, and `npm pack` is slow.
export const getFilesToBePacked = pMemoize(async rootDirectory => {
const {stdout} = await execa('npm', [
'pack',
'--dry-run',
'--json',
'--silent',
'--ignore-scripts',
// Use npm's packlist because it determines the files in published tarballs.
// Bypass npm's `devEngines.packageManager` mismatch check for this inspection.
'--force',
// TODO: Remove this once [npm/cli#7354](https://github.com/npm/cli/issues/7354) is resolved.
'--foreground-scripts=false',
], {cwd: rootDirectory});
try {
// 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 '{' or '[' character.
// Related: https://github.com/sindresorhus/np/issues/742
const jsonStart = stdout.search(/[\[\{]/v);
const parsed = JSON.parse(stdout.slice(jsonStart));
// Unwrap: npm 12 keys the output by package name (`{"pkg": {files}}`); older npm returns an array (`[{files}]`).
const {files} = (Array.isArray(parsed) ? parsed : Object.values(parsed))[0];
return files.map(file => file.path);
} catch (error) {
throw new Error('Failed to parse output of npm pack', {cause: error});
}
});
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 => {
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, {ignoreScripts = false, ignoreScriptsConfigFile = '.npmrc'} = {}) => {
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(/^\.\//v, '');
if (seenPaths.has(normalizedPath)) {
continue;
}
seenPaths.add(normalizedPath);
if (!packedFiles.has(normalizedPath)) {
missingEntryPoints.push(entryPoint);
}
}
if (missingEntryPoints.length > 0) {
const hasLifecycleScript = hasPackLifecycleScript(package_);
// A pack lifecycle script (e.g. `prepack`) may generate these files during publish, so we normally trust it. But when `ignore-scripts` is enabled the script won't run, and the package would be published without them.
if (hasLifecycleScript && !ignoreScripts) {
return;
}
const missing = missingEntryPoints.map(({field, path: entryPath}) => ` "${field}": ${entryPath}`).join('\n');
// Reaching here with a lifecycle script means `ignoreScripts` is true (otherwise we returned above).
if (hasLifecycleScript) {
throw new Error(`Missing entry points in published files:\n${missing}\n\nThese are generated by a pack lifecycle script (e.g. "prepack"), but "ignore-scripts" is enabled in your package manager config, so it won't run during publish and the package would be published without them.\n\nSet the "ignore-scripts" setting to "false" in a project "${ignoreScriptsConfigFile}" to override your global setting.`);
}
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'];
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 parseNpmViewOutput(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;
}
};