install-peerdeps
Version:
CLI to automatically install peerDeps
369 lines (339 loc) • 13.3 kB
JavaScript
/* eslint-disable no-param-reassign, no-shadow, consistent-return */
import "@babel/polyfill";
import { spawn } from "child_process";
import fs from "fs";
import { maxSatisfying } from "semver";
import * as C from "./constants";
/**
* Encodes the package name for use in a URL/HTTP request
* @param {string} packageName - the name of the package to encode
*/
function encodePackageName(packageName) {
// Thanks https://github.com/unpkg/npm-http-server/blob/master/modules/RegistryUtils.js
// for scoped modules help
let encodedPackageName;
if (packageName[0] === "@") {
// For the registry URL, the @ doesn't get URL encoded for some reason
encodedPackageName = `@${encodeURIComponent(packageName.substring(1))}`;
} else {
encodedPackageName = encodeURIComponent(packageName);
}
return encodedPackageName;
}
/**
* Spawns a command to the shell
* @param {string} command - the command to spawn; this must be sanitized per https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2#command-injection-via-args-parameter-of-child_processspawn-without-shell-option-enabled-on-windows-cve-2024-27980---high
* @param {array} args - list of arguments to pass to the command
* @returns {Promise} - a Promise which resolves when the install process is finished
*/
const spawnCommand = (command, args) => {
const isWindows = process.platform === "win32";
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
const cmdProcess = spawn(
`${command}${isWindows && !command.endsWith(".cmd") ? ".cmd" : ""}`,
args,
{
cwd: process.cwd(),
// See:
// - https://github.com/nathanhleung/install-peerdeps/issues/252
// - https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2#command-injection-via-args-parameter-of-child_processspawn-without-shell-option-enabled-on-windows-cve-2024-27980---high
shell: isWindows
}
);
cmdProcess.stdout.on("data", (chunk) => {
if (chunk instanceof Buffer) {
stdout += chunk.toString("utf8");
} else {
stdout += chunk;
}
});
cmdProcess.stderr.on("data", (chunk) => {
if (chunk instanceof Buffer) {
stderr += chunk.toString("utf8");
} else {
stderr += chunk;
}
});
cmdProcess.on("error", reject);
cmdProcess.on("exit", (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(`${[stdout, stderr].filter(Boolean).join("\n\n")}`));
}
});
});
};
/**
* Gets the current Yarn version
* @returns {string} - The current Yarn version
*/
function getYarnVersion() {
return spawnCommand("yarn", ["--version"]).then((it) => it.trim());
}
/**
* Parse a registry manifest to get the best matching version
* @param {Object} requestInfo - information needed to make the request for the data
* @param {string} requestInfo.data - the data from the remote registry
* @param {string} requestInfo.version - the version (or version tag) to try to find
* @returns {string} - The best matching version number
*/
function findPackageVersion({ data, version }) {
// Get the max satisfying semver version
const versionToInstall = maxSatisfying(data.versions, version);
if (versionToInstall) {
return versionToInstall;
}
// When no matching semver, try named tags, like "latest"
if (data["dist-tags"][version]) {
return data["dist-tags"][version];
}
// No match
throw new Error("That version or tag does not exist.");
}
/**
* Gets metadata about the package from the provided registry
* @param {Object} requestInfo - information needed to make the request for the data
* @param {string} requestInfo.packageName - the name of the package
* @param {string} requestInfo.packageManager - the package manager to use
* @param {string} requestInfo.version - the version (or version tag) to attempt to install
* @returns {Promise<Object>} - a Promise which resolves to the JSON response from the registry
*/
function getPackageData({ packageName, packageManager, version }) {
const pkgString = version ? `${packageName}@${version}` : packageName;
return getYarnVersion()
.then((yarnVersion) => {
// Only return `yarnVersion` when we're using Yarn
if (packageManager !== C.yarn) {
return null;
}
return yarnVersion;
})
.catch((err) => {
if (packageManager === C.yarn) {
throw err;
}
// If we're not trying to install with Yarn, we won't re-throw and instead will ignore the error and continue
return null;
})
.then((yarnVersion) => {
// In Yarn versions >= 2, the `yarn info` command was replaced with `yarn npm info`
const isUsingYarnGreaterThan1 =
yarnVersion && !yarnVersion.startsWith("1.");
const args = [
...(isUsingYarnGreaterThan1 ? ["npm", "info"] : ["info"]),
pkgString,
"--json"
];
return spawnCommand(packageManager, args).then((response) => {
const parsed = JSON.parse(response);
// Yarn 1 returns with an extra nested { data } that NPM doesn't
return parsed.data || parsed;
});
});
}
/**
* Gets the contents of the package.json for a package at a specific version
* @param {Object} requestInfo - information needed to make the request for the data
* @param {string} requestInfo.packageName - the name of the package
* @param {Boolean} requestInfo.noRegistry - Gets the package dependencies list from the local node_modules instead of remote registry
* @param {string} requestInfo.packageManager - the package manager to use
* @param {string} requestInfo.version - the version (or version tag) to attempt to install. Ignored if an installed version of the package is found in node_modules.
* @returns {Promise<Object>} - a Promise which resolves to the JSON response from the registry
*/
function getPackageJson({ packageName, noRegistry, packageManager, version }) {
// Local package.json
if (noRegistry) {
if (fs.existsSync(`node_modules/${packageName}`)) {
return Promise.resolve(
JSON.parse(
fs.readFileSync(`node_modules/${packageName}/package.json`, "utf8")
)
);
}
}
// Remote registry
return getPackageData({ packageName, packageManager, version })
.then((data) => {
if (Array.isArray(data)) {
// If `version` is a range (like `^1.0.0` as opposed to `1.0.0`), the
// `info` command returns an array of info objects, one for each
// version that matches the range. The last one seems to be the latest
// (at least with `npm info`), so we grab that one.
data = data[data.length - 1];
}
return Promise.resolve(
findPackageVersion({
data,
version
})
);
})
.then((version) => {
return getPackageData({
packageName,
packageManager,
version
});
});
}
/**
* Builds the package install string based on the version
* @param {Object} options - information needed to build a package install string
* @param {string} options.name - name of the package
* @param {string} options.version - version string of the package
* @returns {string} - the package name and version formatted for an install command
*/
const getPackageString = ({ name, version }) => {
// check for whitespace
if (version.indexOf(" ") >= 0) {
// Semver ranges can have a join of comparator sets
// e.g. '^3.0.2 || ^4.0.0' or '>=1.2.7 <1.3.0'
// Take the last version in the range
const rangeSplit = version.split(" ");
const versionToInstall = rangeSplit[rangeSplit.length - 1];
if (versionToInstall === null) {
return name;
}
return `${name}@${versionToInstall}`;
}
return `${name}@${version}`;
};
/**
* Installs the peer dependencies of the provided packages
* @param {Object} options - options for the install child_process
* @param {string} options.packageName - the name of the package for which to install peer dependencies
* @param {string} options.version - the version of the package
* @param {C.npm | C.yarn | C.pnpm} options.packageManager - the package manager to use (Yarn or npm)
* @param {string} options.noRegistry - Disable going to a remote registry to find a list of peers. Use local node_modules instead
* @param {string} options.dev - whether to install the dependencies as devDependencies
* @param {boolean} options.onlyPeers - whether to install the package itself or only its peers
* @param {boolean} options.silent - whether to save the new dependencies to package.json (NPM only)
* @param {boolean} options.dryRun - whether to actually install the packages or just display
* the resulting command
* @param {Function} cb - the callback to call when the install process is finished
*/
function installPeerDeps(
{
packageName,
version,
packageManager,
noRegistry,
dev,
global,
onlyPeers,
silent,
dryRun,
extraArgs
},
cb
) {
getPackageJson({ packageName, noRegistry, packageManager, version })
// Catch before .then because the .then is so long
.catch((err) => cb(err))
.then((data) => {
// Get peer dependencies for max satisfying version
let peerDepsVersionMap = data.peerDependencies;
if (!peerDepsVersionMap) {
peerDepsVersionMap = {};
}
if (Object.keys(peerDepsVersionMap).length === 0) {
console.warn(
"The package you are trying to install has no peer dependencies. Installing anyway."
);
}
// Construct packages string with correct versions for install
// If onlyPeers option is true, don't install the package itself,
// only its peers.
let packagesString = onlyPeers ? "" : `${packageName}@${data.version}`;
const packageList = Object.keys(peerDepsVersionMap).map((name) =>
getPackageString({
name,
version: peerDepsVersionMap[name]
})
);
if (packageList.length > 0) {
packagesString = `${packagesString} ${packageList.join(" ")}`;
}
// Construct command based on package manager of current project
let globalFlag = packageManager === C.yarn ? "global" : "--global";
if (!global) {
globalFlag = "";
}
const subcommand = packageManager === C.yarn ? "add" : "install";
let devFlag = packageManager === C.yarn ? "--dev" : "--save-dev";
if (!dev) {
devFlag = "";
}
let args = [];
// I know I can push it, but I'll just
// keep concatenating for consistency
// global must preceed add in yarn; npm doesn't care
args = args.concat(globalFlag);
args = args.concat(subcommand);
// See issue #33 - issue with "-0"
function fixPackageName(packageName) {
if (packageName.substr(-2) === "-0") {
// Remove -0
return packageName.substr(0, packageName.length - 2);
}
return `${packageName}`;
}
// If we have spaces in our args spawn()
// cries foul so we'll split the packagesString
// into an array of individual packages
args = args.concat(packagesString.split(" ").map(fixPackageName));
// If devFlag is empty, then we'd be adding an empty arg
// That causes the command to fail
if (devFlag !== "") {
args = args.concat(devFlag);
}
// If we're using NPM, and there's no dev flag,
// and it's not a silent install and it's not a global install
// make sure to save deps in package.json under "dependencies"
if (devFlag === "" && packageManager === C.npm && !silent && !global) {
args = args.concat("--save");
}
// If we are using NPM, and there's no dev flag,
// and it IS a silent install,
// explicitly pass the --no-save flag
// (NPM v5+ defaults to using --save)
if (
devFlag === "" &&
// npm and pnpm are generally interchangeable,
// but pnpm doesn't have a --save option (see above)
[C.npm, C.pnpm].includes(packageManager) &&
silent
) {
args = args.concat("--no-save");
}
// Pass extra args through
if (extraArgs !== "") {
args = args.concat(extraArgs);
}
// Remove empty args
// There's a bug with Yarn 1.0 in which an empty arg
// causes the install process to fail with a "malformed
// response from the registry" error
args = args.filter((a) => a !== "");
// Show user the command that's running
const commandString = `${packageManager} ${args.join(" ")}\n`;
if (dryRun) {
console.log(
`This command would have been run to install ${packageName}@${version}:`
);
console.log(commandString);
} else {
console.log(`Installing peerdeps for ${packageName}@${version}.`);
console.log(commandString);
spawnCommand(packageManager, args)
.then(() => cb(null))
.catch((err) => cb(err));
}
});
}
// Export for testing
export { encodePackageName, getPackageData };
export default installPeerDeps;