273 lines
8.5 KiB
Plaintext
273 lines
8.5 KiB
Plaintext
import path from 'node:path';
|
|
import semver from 'semver';
|
|
import urlJoin from 'url-join';
|
|
import Plugin from '../Plugin.js';
|
|
import { hasAccess, rejectAfter, parseVersion, readJSON, e } from '../../util.js';
|
|
import prompts from './prompts.js';
|
|
|
|
const docs = 'https://git.io/release-it-npm';
|
|
|
|
const options = { write: false };
|
|
|
|
const MANIFEST_PATH = './package.json';
|
|
const DEFAULT_TAG = 'latest';
|
|
const DEFAULT_TAG_PRERELEASE = 'next';
|
|
const NPM_BASE_URL = 'https://www.npmjs.com';
|
|
|
|
const fixArgs = args => (args ? (typeof args === 'string' ? args.split(' ') : args) : []);
|
|
|
|
class npm extends Plugin {
|
|
static isEnabled(options) {
|
|
return hasAccess(MANIFEST_PATH) && options !== false;
|
|
}
|
|
|
|
constructor(...args) {
|
|
super(...args);
|
|
this.registerPrompts(prompts);
|
|
}
|
|
|
|
async init() {
|
|
const { name, version: latestVersion, private: isPrivate, publishConfig } = readJSON(path.resolve(MANIFEST_PATH));
|
|
this.setContext({ name, latestVersion, private: isPrivate, publishConfig });
|
|
this.config.setContext({ npm: { name } });
|
|
|
|
const { publish, skipChecks } = this.options;
|
|
|
|
const timeout = Number(this.options.timeout) * 1000;
|
|
|
|
if (publish === false || isPrivate) return;
|
|
|
|
if (skipChecks) return;
|
|
|
|
const validations = Promise.all([this.isRegistryUp(), this.isAuthenticated(), this.getLatestRegistryVersion()]);
|
|
|
|
await Promise.race([validations, rejectAfter(timeout, e(`Timed out after ${timeout}ms.`, docs))]);
|
|
|
|
const [isRegistryUp, isAuthenticated, latestVersionInRegistry] = await validations;
|
|
|
|
if (!isRegistryUp) {
|
|
throw e(`Unable to reach npm registry (timed out after ${timeout}ms).`, docs);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
throw e('Not authenticated with npm. Please `npm login` and try again.', docs);
|
|
}
|
|
|
|
if (!(await this.isCollaborator())) {
|
|
const { username } = this.getContext();
|
|
throw e(`User ${username} is not a collaborator for ${name}.`, docs);
|
|
}
|
|
|
|
if (!latestVersionInRegistry) {
|
|
this.log.warn('No version found in npm registry. Assuming new package.');
|
|
} else {
|
|
if (!semver.eq(latestVersion, latestVersionInRegistry)) {
|
|
this.log.warn(
|
|
`Latest version in registry (${latestVersionInRegistry}) does not match package.json (${latestVersion}).`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
getName() {
|
|
return this.getContext('name');
|
|
}
|
|
|
|
getLatestVersion() {
|
|
return this.options.ignoreVersion ? null : this.getContext('latestVersion');
|
|
}
|
|
|
|
async bump(version) {
|
|
const tag = this.options.tag || (await this.resolveTag(version));
|
|
this.setContext({ version, tag });
|
|
|
|
if (!this.config.isIncrement) return false;
|
|
|
|
const { versionArgs, allowSameVersion } = this.options;
|
|
const args = [version, '--no-git-tag-version', allowSameVersion && '--allow-same-version', ...fixArgs(versionArgs)];
|
|
const task = () => this.exec(`npm version ${args.filter(Boolean).join(' ')}`);
|
|
return this.spinner.show({ task, label: 'npm version' });
|
|
}
|
|
|
|
release() {
|
|
if (this.options.publish === false) return false;
|
|
if (this.getContext('private')) return false;
|
|
const publish = () => this.publish({ otpCallback });
|
|
const otpCallback =
|
|
this.config.isCI && !this.config.isPromptOnlyVersion ? null : task => this.step({ prompt: 'otp', task });
|
|
return this.step({ task: publish, label: 'npm publish', prompt: 'publish' });
|
|
}
|
|
|
|
isRegistryUp() {
|
|
const registry = this.getRegistry();
|
|
const registryArg = registry ? ` --registry ${registry}` : '';
|
|
return this.exec(`npm ping${registryArg}`, { options }).then(
|
|
() => true,
|
|
err => {
|
|
if (/code E40[04]|404.*(ping not found|No content for path)/.test(err)) {
|
|
this.log.warn('Ignoring response from unsupported `npm ping` command.');
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
);
|
|
}
|
|
|
|
isAuthenticated() {
|
|
const registry = this.getRegistry();
|
|
const registryArg = registry ? ` --registry ${registry}` : '';
|
|
return this.exec(`npm whoami${registryArg}`, { options }).then(
|
|
output => {
|
|
const username = output ? output.trim() : null;
|
|
this.setContext({ username });
|
|
return true;
|
|
},
|
|
err => {
|
|
this.debug(err);
|
|
if (/code E40[04]/.test(err)) {
|
|
this.log.warn('Ignoring response from unsupported `npm whoami` command.');
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
);
|
|
}
|
|
|
|
async isCollaborator() {
|
|
const registry = this.getRegistry();
|
|
const registryArg = registry ? ` --registry ${registry}` : '';
|
|
const name = this.getName();
|
|
const { username } = this.getContext();
|
|
if (username === undefined) return true;
|
|
if (username === null) return false;
|
|
|
|
try {
|
|
let npmVersion = await this.exec('npm --version', { options });
|
|
|
|
let accessCommand;
|
|
if (semver.gt(npmVersion, '9.0.0')) {
|
|
accessCommand = 'npm access list collaborators --json';
|
|
} else {
|
|
accessCommand = 'npm access ls-collaborators';
|
|
}
|
|
|
|
const output = await this.exec(`${accessCommand} ${name}${registryArg}`, { options });
|
|
|
|
try {
|
|
const collaborators = JSON.parse(output);
|
|
const permissions = collaborators[username];
|
|
return permissions && permissions.includes('write');
|
|
} catch (err) {
|
|
this.debug(err);
|
|
return false;
|
|
}
|
|
} catch (err) {
|
|
this.debug(err);
|
|
if (/code E400/.test(err)) {
|
|
this.log.warn('Ignoring response from unsupported `npm access` command.');
|
|
} else {
|
|
this.log.warn(`Unable to verify if user ${username} is a collaborator for ${name}.`);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async getLatestRegistryVersion() {
|
|
const registry = this.getRegistry();
|
|
const registryArg = registry ? ` --registry ${registry}` : '';
|
|
const name = this.getName();
|
|
const latestVersion = this.getLatestVersion();
|
|
const tag = await this.resolveTag(latestVersion);
|
|
return this.exec(`npm show ${name}@${tag} version${registryArg}`, { options }).catch(() => null);
|
|
}
|
|
|
|
getRegistryPreReleaseTags() {
|
|
return this.exec(`npm view ${this.getName()} dist-tags --json`, { options }).then(
|
|
output => {
|
|
try {
|
|
const tags = JSON.parse(output);
|
|
return Object.keys(tags).filter(tag => tag !== DEFAULT_TAG);
|
|
} catch (err) {
|
|
this.debug(err);
|
|
return [];
|
|
}
|
|
},
|
|
() => []
|
|
);
|
|
}
|
|
|
|
getPackageUrl() {
|
|
const baseUrl = this.getRegistry() || NPM_BASE_URL;
|
|
return urlJoin(baseUrl, 'package', this.getName());
|
|
}
|
|
|
|
getRegistry() {
|
|
const { publishConfig } = this.getContext();
|
|
const registries = publishConfig
|
|
? publishConfig.registry
|
|
? [publishConfig.registry]
|
|
: Object.keys(publishConfig)
|
|
.filter(key => key.endsWith('registry'))
|
|
.map(key => publishConfig[key])
|
|
: [];
|
|
return registries[0];
|
|
}
|
|
|
|
async guessPreReleaseTag() {
|
|
const [tag] = await this.getRegistryPreReleaseTags();
|
|
if (tag) {
|
|
return tag;
|
|
} else {
|
|
this.log.warn(`Unable to get pre-release tag(s) from npm registry. Using "${DEFAULT_TAG_PRERELEASE}".`);
|
|
return DEFAULT_TAG_PRERELEASE;
|
|
}
|
|
}
|
|
|
|
async resolveTag(version) {
|
|
const { tag } = this.options;
|
|
const { isPreRelease, preReleaseId } = parseVersion(version);
|
|
if (!isPreRelease) {
|
|
return DEFAULT_TAG;
|
|
} else {
|
|
return tag || preReleaseId || (await this.guessPreReleaseTag());
|
|
}
|
|
}
|
|
|
|
async publish({ otp = this.options.otp, otpCallback } = {}) {
|
|
const { publishPath = '.', publishArgs } = this.options;
|
|
const { private: isPrivate, tag = DEFAULT_TAG } = this.getContext();
|
|
const otpArg = otp ? `--otp ${otp}` : '';
|
|
const dryRunArg = this.config.isDryRun ? '--dry-run' : '';
|
|
if (isPrivate) {
|
|
this.log.warn('Skip publish: package is private.');
|
|
return false;
|
|
}
|
|
const args = [publishPath, `--tag ${tag}`, otpArg, dryRunArg, ...fixArgs(publishArgs)].filter(Boolean);
|
|
return this.exec(`npm publish ${args.join(' ')}`, { options })
|
|
.then(() => {
|
|
this.setContext({ isReleased: true });
|
|
})
|
|
.catch(err => {
|
|
this.debug(err);
|
|
if (/one-time pass/.test(err)) {
|
|
if (otp != null) {
|
|
this.log.warn('The provided OTP is incorrect or has expired.');
|
|
}
|
|
if (otpCallback) {
|
|
return otpCallback(otp => this.publish({ otp, otpCallback }));
|
|
}
|
|
}
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
afterRelease() {
|
|
const { isReleased } = this.getContext();
|
|
if (isReleased) {
|
|
this.log.log(`🔗 ${this.getPackageUrl()}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export default npm;
|