-
Notifications
You must be signed in to change notification settings - Fork 21
/
Copy pathinstall-peerdeps.js
358 lines (330 loc) · 12.6 KB
/
install-peerdeps.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
/* 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 => {
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
const peerDepsVersionMap = data.peerDependencies;
if (typeof peerDepsVersionMap === "undefined") {
throw new Error(
"The package you are trying to install has no peer " +
"dependencies. Use yarn or npm to install it manually."
);
}
// 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;