@nx/js
Version:
472 lines (468 loc) • 23.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = runExecutor;
const devkit_1 = require("@nx/devkit");
const child_process_1 = require("child_process");
const fs_1 = require("fs");
const npm_run_path_1 = require("npm-run-path");
const path_1 = require("path");
const is_locally_linked_package_version_1 = require("../../utils/is-locally-linked-package-version");
const npm_config_1 = require("../../utils/npm-config");
const extract_npm_publish_json_data_1 = require("./extract-npm-publish-json-data");
const log_tar_1 = require("./log-tar");
const chalk = require("chalk");
const LARGE_BUFFER = 1024 * 1000000;
function processEnv(color) {
const env = {
...process.env,
...(0, npm_run_path_1.env)(),
};
if (color) {
env.FORCE_COLOR = `${color}`;
}
return env;
}
function isAlreadyPublishedPublishError(stdoutData, stderr = '', stdout = '') {
const error = stdoutData?.error;
const errorMessage = [
error?.summary,
error?.detail,
error?.message,
error?.body?.error,
stderr,
stdout,
]
.filter(Boolean)
.join('\n');
return (error?.code === 'EPUBLISHCONFLICT' ||
errorMessage.includes('You cannot publish over the previously published versions') ||
(error?.code === 'E409' &&
errorMessage.includes('this package is already present')));
}
async function runExecutor(options, context) {
const pm = (0, devkit_1.detectPackageManager)();
// Check if npm is installed (needed for dist-tag management and as fallback for view command)
let isNpmInstalled = false;
try {
isNpmInstalled =
(0, child_process_1.execSync)('npm --version', {
encoding: 'utf-8',
windowsHide: true,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim() !== '';
}
catch {
// Allow missing npm only when using bun
if (pm !== 'bun') {
console.error(`npm was not found in the current environment. This is only supported when using \`bun\` as a package manager, but your detected package manager is "${pm}"`);
return {
success: false,
};
}
}
/**
* We need to check both the env var and the option because the executor may have been triggered
* indirectly via dependsOn, in which case the env var will be set, but the option will not.
*/
const isDryRun = process.env.NX_DRY_RUN === 'true' || options.dryRun || false;
const projectConfig = context.projectsConfigurations.projects[context.projectName];
const packageRoot = (0, path_1.join)(context.root, options.packageRoot ?? projectConfig.root);
const packageJsonPath = (0, path_1.join)(packageRoot, 'package.json');
const packageJson = (0, devkit_1.readJsonFile)(packageJsonPath);
const packageName = packageJson.name;
/**
* Whether or not dynamically replacing local dependency protocols (such as "workspace:*") is supported during `nx release publish` is
* dependent on the package manager the user is using.
*
* npm does not support the workspace protocol at all, and `npm publish` does not support dynamically updating locally linked packages
* during its packing phase, so we give the user a clear error message informing them of that.
*
* - `pnpm publish` provides ideal support, it has the possibility of providing JSON output consistent with npm
* - `bun publish`, provides very good support, including all the flags we need apart from the JSON output, so we just have to accept that
* it will look and feel different and print what it gives us and perform one bit of string manipulation for the dry-run case.
* - `yarn npm publish`, IS NOT YET SUPPORTED, and will be tricky because it does not support the majority of the flags we need. However, it
* does support replacing local dependency protocols with the correct version during its packing phase.
*/
if (pm === 'npm' || pm === 'yarn') {
const depTypes = ['dependencies', 'devDependencies', 'peerDependencies'];
for (const depType of depTypes) {
const deps = packageJson[depType];
if (deps) {
for (const depName in deps) {
if ((0, is_locally_linked_package_version_1.isLocallyLinkedPackageVersion)(deps[depName])) {
if (pm === 'npm') {
console.error(`Error: Cannot publish package "${packageName}" because it contains a local dependency protocol in its "${depType}", and your package manager is npm.
Please update the local dependency on "${depName}" to be a valid semantic version (e.g. using \`nx release\`) before publishing, or switch to pnpm or bun as a package manager, which support dynamically replacing these protocols during publishing.`);
}
else if (pm === 'yarn') {
console.error(`Error: Cannot publish package "${packageName}" because it contains a local dependency protocol in its "${depType}", and your package manager is yarn.
Currently, yarn is not supported for this use case because its \`yarn npm publish\` command does not support the customization needed.
Please update the local dependency on "${depName}" to be a valid semantic version (e.g. using \`nx release\`) before publishing, or switch to pnpm or bun as a package manager, which support dynamically replacing these protocols during publishing.`);
}
return {
success: false,
};
}
}
}
}
}
// If package and project name match, we can make log messages terser
let packageTxt = packageName === context.projectName
? `package "${packageName}"`
: `package "${packageName}" from project "${context.projectName}"`;
if (packageJson.private === true) {
console.warn(`Skipped ${packageTxt}, because it has \`"private": true\` in ${packageJsonPath}`);
return {
success: true,
};
}
/**
* If version data was provided by the nx release version step, check if this project
* actually had a new version resolved. If not (newVersion is null), there is nothing
* to publish, so we can skip this project entirely.
*/
if (options.nxReleaseVersionData) {
const projectVersionData = options.nxReleaseVersionData[context.projectName];
if (projectVersionData && projectVersionData.newVersion === null) {
console.warn(`Skipped ${packageTxt}, because no new version was resolved for this project`);
return {
success: true,
};
}
}
const warnFn = (message) => {
console.log(chalk.keyword('orange')(message));
};
const { registry, tag, registryConfigKey } = await (0, npm_config_1.parseRegistryOptions)(context.root, {
packageRoot,
packageJson,
}, {
registry: options.registry,
tag: options.tag,
}, warnFn);
// Use bun info when bun is the package manager, otherwise use npm view
// (npm view works across npm/pnpm/yarn environments and is the established default)
const npmViewCommandSegments = pm === 'bun'
? ['bun info', packageName, `--json --"${registryConfigKey}=${registry}"`]
: [
`npm view ${packageName} versions dist-tags --json --"${registryConfigKey}=${registry}"`,
];
const npmDistTagAddCommandSegments = [
`npm dist-tag add ${packageName}@${packageJson.version} ${tag} --"${registryConfigKey}=${registry}"`,
];
/**
* In a dry-run scenario, it is most likely that all commands are being run with dry-run, therefore
* the most up to date/relevant version might not exist on disk for us to read and make the npm view
* request with.
*
* Therefore, so as to not produce misleading output in dry around dist-tags being altered, we do not
* perform the npm view step, and just show npm/pnpm publish's dry-run output.
*/
if (!isDryRun && !options.firstRelease) {
const currentVersion = packageJson.version;
try {
const result = (0, child_process_1.execSync)(npmViewCommandSegments.join(' '), {
env: processEnv(true),
cwd: context.root,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
const resultJson = JSON.parse(result.toString());
const distTags = resultJson['dist-tags'] || {};
if (distTags[tag] === currentVersion) {
console.warn(`Skipped ${packageTxt} because v${currentVersion} already exists in ${registry} with tag "${tag}"`);
return {
success: true,
};
}
if (isNpmInstalled) {
// If only one version of a package exists in the registry, versions will be a string instead of an array.
const versions = Array.isArray(resultJson.versions)
? resultJson.versions
: [resultJson.versions];
if (versions.includes(currentVersion)) {
try {
if (!isDryRun) {
(0, child_process_1.execSync)(npmDistTagAddCommandSegments.join(' '), {
env: processEnv(true),
cwd: context.root,
stdio: 'ignore',
windowsHide: true,
});
console.log(`Added the dist-tag ${tag} to v${currentVersion} for registry ${registry}.\n`);
}
else {
console.log(`Would add the dist-tag ${tag} to v${currentVersion} for registry ${registry}, but ${chalk.keyword('orange')('[dry-run]')} was set.\n`);
}
return {
success: true,
};
}
catch (err) {
try {
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
// If the error is that the package doesn't exist, then we can ignore it because we will be publishing it for the first time in the next step
if (!(stdoutData.error?.code?.includes('E404') &&
stdoutData.error?.summary?.includes('no such package available')) &&
!(err.stderr?.toString().includes('E404') &&
err.stderr?.toString().includes('no such package available'))) {
console.error('npm dist-tag add error:');
// npm returns error.summary and error.detail
if (stdoutData.error?.summary) {
console.error(stdoutData.error.summary);
}
if (stdoutData.error?.detail) {
console.error(stdoutData.error.detail);
}
// pnpm returns error.code and error.message
if (stdoutData.error?.code && !stdoutData.error?.summary) {
console.error(`Error code: ${stdoutData.error.code}`);
}
if (stdoutData.error?.message && !stdoutData.error?.summary) {
console.error(stdoutData.error.message);
}
if (context.isVerbose) {
console.error('npm dist-tag add stdout:');
console.error(JSON.stringify(stdoutData, null, 2));
}
return {
success: false,
};
}
}
catch (err) {
console.error('Something unexpected went wrong when processing the npm dist-tag add output\n', err);
return {
success: false,
};
}
}
}
}
}
catch (err) {
try {
const stdoutData = JSON.parse(err.stdout?.toString() || '{}');
// If the error is that the package doesn't exist, then we can ignore it because we will be publishing it for the first time in the next step
if (!(stdoutData.error?.code?.includes('E404') &&
stdoutData.error?.summary?.toLowerCase().includes('not found')) &&
!(err.stderr?.toString().includes('E404') &&
err.stderr?.toString().toLowerCase().includes('not found')) &&
// bun uses plain '404' instead of 'E404'
!(err.stderr?.toString().includes('404') &&
err.stderr?.toString().toLowerCase().includes('not found'))) {
console.error(`Something unexpected went wrong when checking for existing dist-tags.\n`, err);
return {
success: false,
};
}
}
catch {
// JSON parse failed entirely — check stderr/stdout for plain 404
const stderrStr = err.stderr?.toString() || '';
const stdoutStr = err.stdout?.toString() || '';
if (!((stderrStr.includes('404') &&
stderrStr.toLowerCase().includes('not found')) ||
(stdoutStr.includes('404') &&
stdoutStr.toLowerCase().includes('not found')))) {
console.error(`Something unexpected went wrong when checking for existing dist-tags.\n`, err);
return {
success: false,
};
}
}
}
}
if (options.firstRelease && context.isVerbose) {
console.log('Skipped npm view because --first-release was set');
}
/**
* NOTE: If this is ever changed away from running the command at the workspace root and pointing at the package root (e.g. back
* to running from the package root directly), then special attention should be paid to the fact that npm/pnpm publish will nest its
* JSON output under the name of the package in that case (and it would need to be handled below).
*/
return runPublish({
pm,
options,
context,
packageRoot,
packageJson,
registry,
registryConfigKey,
tag,
isDryRun,
isNpmInstalled,
packageTxt,
});
}
function runPublish(ctx) {
const { pm, options, context, packageRoot, packageJson, registry, registryConfigKey, tag, isDryRun, isNpmInstalled, packageTxt, } = ctx;
const pmCommand = (0, devkit_1.getPackageManagerCommand)(pm);
const publishCommandSegments = [
pmCommand.publish(packageRoot, registry, registryConfigKey, tag),
];
if (options.otp) {
publishCommandSegments.push(`--otp=${options.otp}`);
}
if (options.access) {
publishCommandSegments.push(`--access=${options.access}`);
}
if (isDryRun) {
publishCommandSegments.push(`--dry-run`);
}
try {
const output = (0, child_process_1.execSync)(publishCommandSegments.join(' '), {
maxBuffer: LARGE_BUFFER,
env: processEnv(true),
cwd: context.root,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
// If in dry-run mode, the version on disk will not represent the version that would be published, so we scrub it from the output to avoid confusion.
const dryRunVersionPlaceholder = 'X.X.X-dry-run';
const publishSummaryMessage = isDryRun
? `Would publish to ${registry} with tag "${tag}", but ${chalk.keyword('orange')('[dry-run]')} was set`
: `Published to ${registry} with tag "${tag}"`;
// bun publish does not support outputting JSON, so we need to modify and print the output string directly
if (pm === 'bun') {
let outputStr = output.toString();
if (isDryRun) {
outputStr = outputStr.replace(new RegExp(`${packageJson.name}@${packageJson.version}`, 'g'), `${packageJson.name}@${dryRunVersionPlaceholder}`);
}
console.log(outputStr);
console.log(publishSummaryMessage);
return {
success: true,
};
}
/**
* We cannot JSON.parse the output directly because if the user is using lifecycle scripts, npm/pnpm will mix its publish output with the JSON output all on stdout.
* Additionally, we want to capture and show the lifecycle script outputs as beforeJsonData and afterJsonData and print them accordingly below.
*/
const { beforeJsonData, jsonData, afterJsonData } = (0, extract_npm_publish_json_data_1.extractNpmPublishJsonData)(output.toString());
if (!jsonData) {
console.error(`The ${pm} publish output data could not be extracted. Please report this issue on https://github.com/nrwl/nx`);
return {
success: false,
};
}
// pnpm 11 dropped the per-file size field from its publish --json output,
// so recover the sizes from disk for the tarball contents log.
if (Array.isArray(jsonData.files)) {
for (const file of jsonData.files) {
if (typeof file.size !== 'number' && typeof file.path === 'string') {
try {
file.size = (0, fs_1.statSync)((0, path_1.join)(packageRoot, file.path)).size;
}
catch {
// leave the size unknown, logTar omits it
}
}
}
}
if (isDryRun) {
for (const [key, val] of Object.entries(jsonData)) {
if (typeof val !== 'string') {
continue;
}
jsonData[key] = val.replace(new RegExp(packageJson.version, 'g'), dryRunVersionPlaceholder);
}
}
if (typeof beforeJsonData === 'string' &&
beforeJsonData.trim().length > 0) {
console.log(beforeJsonData);
}
(0, log_tar_1.logTar)(jsonData);
if (typeof afterJsonData === 'string' && afterJsonData.trim().length > 0) {
console.log(afterJsonData);
}
// Print the summary message after the JSON data has been printed
console.log(publishSummaryMessage);
return {
success: true,
};
}
catch (err) {
try {
// bun publish does not support outputting JSON, so we cannot perform any further processing
if (pm === 'bun') {
const bunStderr = err.stderr?.toString() || '';
const bunStdout = err.stdout?.toString() || '';
// bun publish does not yet support npm's OIDC trusted publishing flow. If the failure
// looks like an authentication error and npm is available, retry via npm to recover.
// Other failures (e.g. version conflict, 403) would produce the same error from npm,
// so we surface bun's error directly instead.
const looksLikeAuthError = /missing authentication|bunx npm login|unauthorized|\b401\b/i.test(bunStderr + bunStdout);
if (isNpmInstalled && looksLikeAuthError) {
console.warn(`bun publish failed with an authentication error; falling back to npm publish (bun does not support npm OIDC trusted publishing).`);
return runPublish({ ...ctx, pm: 'npm' });
}
console.error(`bun publish error:`);
console.error(bunStderr);
console.error(bunStdout);
return {
success: false,
};
}
// stdout is not guaranteed to be JSON (e.g. lifecycle script failures).
// If parsing fails, print raw stderr/stdout so the underlying error is visible to the user.
let stdoutData;
try {
stdoutData = JSON.parse(err.stdout?.toString() || '{}');
}
catch {
const stderr = err.stderr?.toString() || '';
const stdout = err.stdout?.toString() || '';
if (isAlreadyPublishedPublishError(undefined, stderr, stdout)) {
console.warn(`Skipped ${packageTxt}, as v${packageJson.version} has already been published to ${registry} with tag "${tag}"`);
return {
success: true,
};
}
console.error(err.stderr?.toString() || '');
console.error(err.stdout?.toString() || '');
return {
success: false,
};
}
if (isAlreadyPublishedPublishError(stdoutData, err.stderr?.toString() || '', err.stdout?.toString() || '')) {
console.warn(`Skipped ${packageTxt}, as v${packageJson.version} has already been published to ${registry} with tag "${tag}"`);
return {
success: true,
};
}
console.error(`${pm} publish error:`);
// npm returns error.summary and error.detail
if (stdoutData.error?.summary) {
console.error(stdoutData.error.summary);
}
if (stdoutData.error?.detail) {
console.error(stdoutData.error.detail);
}
// pnpm returns error.code and error.message
if (stdoutData.error?.code && !stdoutData.error?.summary) {
console.error(`Error code: ${stdoutData.error.code}`);
}
if (stdoutData.error?.message && !stdoutData.error?.summary) {
console.error(stdoutData.error.message);
}
if (context.isVerbose) {
console.error(`${pm} publish stdout:`);
console.error(JSON.stringify(stdoutData, null, 2));
}
if (!stdoutData.error) {
throw err;
}
return {
success: false,
};
}
catch (err) {
console.error(`Something unexpected went wrong when processing the ${pm} publish output\n`, err);
return {
success: false,
};
}
}
}