/
package-manager.ts
410 lines (375 loc) · 13 KB
/
package-manager.ts
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
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
import { exec, execSync } from 'child_process';
import { copyFileSync, existsSync, writeFileSync } from 'fs';
import { remove } from 'fs-extra';
import { dirname, join, relative } from 'path';
import { gte, lt } from 'semver';
import { dirSync } from 'tmp';
import { promisify } from 'util';
import { readNxJson } from '../config/configuration';
import { readPackageJson } from '../project-graph/file-utils';
import { readFileIfExisting, writeJsonFile } from './fileutils';
import { PackageJson, readModulePackageJson } from './package-json';
import { workspaceRoot } from './workspace-root';
const execAsync = promisify(exec);
export type PackageManager = 'yarn' | 'pnpm' | 'npm' | 'bun';
export interface PackageManagerCommands {
preInstall?: string;
install: string;
ciInstall: string;
updateLockFile: string;
add: string;
addDev: string;
rm: string;
exec: string;
dlx: string;
list: string;
run: (script: string, args: string) => string;
}
/**
* Detects which package manager is used in the workspace based on the lock file.
*/
function getPackageManager(dir: string = '') {
return existsSync(join(dir, 'yarn.lock'))
? 'yarn'
: existsSync(join(dir, 'bun.lockb'))
? 'bun'
: existsSync(join(dir, 'pnpm-lock.yaml')) ||
existsSync(join(dir, 'pnpm-workspace.yaml'))
? 'pnpm'
: 'npm';
}
/**
* Detects which package manager is used in the workspace based on the lock file.
*/
export function detectPackageManager(dir: string = ''): PackageManager {
const nxJson = readNxJson();
return nxJson.cli?.packageManager ?? getPackageManager(dir);
}
/**
* Returns true if the workspace is using npm workspaces, yarn workspaces, or pnpm workspaces.
* @param packageManager The package manager to use. If not provided, it will be detected based on the lock file.
* @param root The directory the commands will be ran inside of. Defaults to the current workspace's root.
*/
export function isWorkspacesEnabled(
packageManager: PackageManager = detectPackageManager(),
root: string = workspaceRoot
): boolean {
if (packageManager === 'pnpm') {
return existsSync(join(root, 'pnpm-workspace.yaml'));
}
// yarn and pnpm both use the same 'workspaces' property in package.json
const packageJson: PackageJson = readPackageJson();
return !!packageJson?.workspaces;
}
/**
* Returns commands for the package manager used in the workspace.
* By default, the package manager is derived based on the lock file,
* but it can also be passed in explicitly.
*
* Example:
*
* ```javascript
* execSync(`${getPackageManagerCommand().addDev} my-dev-package`);
* ```
*
* @param packageManager The package manager to use. If not provided, it will be detected based on the lock file.
* @param root The directory the commands will be ran inside of. Defaults to the current workspace's root.
*/
export function getPackageManagerCommand(
packageManager: PackageManager = detectPackageManager(),
root: string = workspaceRoot
): PackageManagerCommands {
const commands: { [pm in PackageManager]: () => PackageManagerCommands } = {
yarn: () => {
const yarnVersion = getPackageManagerVersion('yarn', root);
const useBerry = gte(yarnVersion, '2.0.0');
return {
preInstall: `yarn set version ${yarnVersion}`,
install: 'yarn',
ciInstall: useBerry
? 'yarn install --immutable'
: 'yarn install --frozen-lockfile',
updateLockFile: useBerry
? 'yarn install --mode update-lockfile'
: 'yarn install',
add: useBerry ? 'yarn add' : 'yarn add -W',
addDev: useBerry ? 'yarn add -D' : 'yarn add -D -W',
rm: 'yarn remove',
exec: 'yarn',
dlx: useBerry ? 'yarn dlx' : 'yarn',
run: (script: string, args: string) => `yarn ${script} ${args}`,
list: useBerry ? 'yarn info --name-only' : 'yarn list',
};
},
pnpm: () => {
const pnpmVersion = getPackageManagerVersion('pnpm', root);
const modernPnpm = gte(pnpmVersion, '6.13.0');
const includeDoubleDashBeforeArgs = lt(pnpmVersion, '7.0.0');
const isPnpmWorkspace = existsSync(join(root, 'pnpm-workspace.yaml'));
return {
install: 'pnpm install --no-frozen-lockfile', // explicitly disable in case of CI
ciInstall: 'pnpm install --frozen-lockfile',
updateLockFile: 'pnpm install --lockfile-only',
add: isPnpmWorkspace ? 'pnpm add -w' : 'pnpm add',
addDev: isPnpmWorkspace ? 'pnpm add -Dw' : 'pnpm add -D',
rm: 'pnpm rm',
exec: modernPnpm ? 'pnpm exec' : 'pnpx',
dlx: modernPnpm ? 'pnpm dlx' : 'pnpx',
run: (script: string, args: string) =>
includeDoubleDashBeforeArgs
? `pnpm run ${script} -- ${args}`
: `pnpm run ${script} ${args}`,
list: 'pnpm ls --depth 100',
};
},
npm: () => {
process.env.npm_config_legacy_peer_deps ??= 'true';
return {
install: 'npm install',
ciInstall: 'npm ci',
updateLockFile: 'npm install --package-lock-only',
add: 'npm install',
addDev: 'npm install -D',
rm: 'npm rm',
exec: 'npx',
dlx: 'npx',
run: (script: string, args: string) => `npm run ${script} -- ${args}`,
list: 'npm ls',
};
},
bun: () => {
return {
install: 'bun install',
ciInstall: 'bun install --no-cache',
updateLockFile: 'bun install --frozen-lockfile',
add: 'bun install',
addDev: 'bun install -D',
rm: 'bun rm',
exec: 'bun',
dlx: 'bunx',
run: (script: string, args: string) => `bun run ${script} -- ${args}`,
list: 'bun pm ls',
};
},
};
return commands[packageManager]();
}
/**
* Returns the version of the package manager used in the workspace.
* By default, the package manager is derived based on the lock file,
* but it can also be passed in explicitly.
*/
export function getPackageManagerVersion(
packageManager: PackageManager = detectPackageManager(),
cwd = process.cwd()
): string {
return execSync(`${packageManager} --version`, {
cwd,
encoding: 'utf-8',
}).trim();
}
/**
* Checks for a project level npmrc file by crawling up the file tree until
* hitting a package.json file, as this is how npm finds them as well.
*/
export function findFileInPackageJsonDirectory(
file: string,
directory: string = process.cwd()
): string | null {
while (!existsSync(join(directory, 'package.json'))) {
directory = dirname(directory);
}
const path = join(directory, file);
return existsSync(path) ? path : null;
}
/**
* We copy yarnrc.yml to the temporary directory to ensure things like the specified
* package registry are still used. However, there are a few relative paths that can
* cause issues, so we modify them to fit the new directory.
*
* Exported for testing - not meant to be used outside of this file.
*
* @param contents The string contents of the yarnrc.yml file
* @returns Updated string contents of the yarnrc.yml file
*/
export function modifyYarnRcYmlToFitNewDirectory(contents: string): string {
const { parseSyml, stringifySyml } = require('@yarnpkg/parsers');
const parsed: {
yarnPath?: string;
plugins?: (string | { path: string; spec: string })[];
} = parseSyml(contents);
if (parsed.yarnPath) {
// yarnPath is relative to the workspace root, so we need to make it relative
// to the new directory s.t. it still points to the same yarn binary.
delete parsed.yarnPath;
}
if (parsed.plugins) {
// Plugins specified by a string are relative paths from workspace root.
// ex: https://yarnpkg.com/advanced/plugin-tutorial#writing-our-first-plugin
delete parsed.plugins;
}
return stringifySyml(parsed);
}
/**
* We copy .yarnrc to the temporary directory to ensure things like the specified
* package registry are still used. However, there are a few relative paths that can
* cause issues, so we modify them to fit the new directory.
*
* Exported for testing - not meant to be used outside of this file.
*
* @param contents The string contents of the yarnrc.yml file
* @returns Updated string contents of the yarnrc.yml file
*/
export function modifyYarnRcToFitNewDirectory(contents: string): string {
const lines = contents.split('\n');
const yarnPathIndex = lines.findIndex((line) => line.startsWith('yarn-path'));
if (yarnPathIndex !== -1) {
lines.splice(yarnPathIndex, 1);
}
return lines.join('\n');
}
export function copyPackageManagerConfigurationFiles(
root: string,
destination: string
) {
for (const packageManagerConfigFile of [
'.npmrc',
'.yarnrc',
'.yarnrc.yml',
'bunfig.toml',
]) {
// f is an absolute path, including the {workspaceRoot}.
const f = findFileInPackageJsonDirectory(packageManagerConfigFile, root);
if (f) {
// Destination should be the same relative path from the {workspaceRoot},
// but now relative to the destination. `relative` makes `{workspaceRoot}/some/path`
// look like `./some/path`, and joining that gets us `{destination}/some/path
const destinationPath = join(destination, relative(root, f));
switch (packageManagerConfigFile) {
case '.npmrc': {
copyFileSync(f, destinationPath);
break;
}
case 'bunfig.toml': {
copyFileSync(f, destinationPath);
break;
}
case '.yarnrc': {
const updated = modifyYarnRcToFitNewDirectory(readFileIfExisting(f));
writeFileSync(destinationPath, updated);
break;
}
case '.yarnrc.yml': {
const updated = modifyYarnRcYmlToFitNewDirectory(
readFileIfExisting(f)
);
writeFileSync(destinationPath, updated);
break;
}
}
}
}
}
/**
* Creates a temporary directory where you can run package manager commands safely.
*
* For cases where you'd want to install packages that require an `.npmrc` set up,
* this function looks up for the nearest `.npmrc` (if exists) and copies it over to the
* temp directory.
*/
export function createTempNpmDirectory() {
const dir = dirSync().name;
// A package.json is needed for pnpm pack and for .npmrc to resolve
writeJsonFile(`${dir}/package.json`, {});
copyPackageManagerConfigurationFiles(workspaceRoot, dir);
const cleanup = async () => {
try {
await remove(dir);
} catch {
// It's okay if this fails, the OS will clean it up eventually
}
};
return { dir, cleanup };
}
/**
* Returns the resolved version for a given package and version tag using the
* NPM registry (when using Yarn it will fall back to NPM to fetch the info).
*/
export async function resolvePackageVersionUsingRegistry(
packageName: string,
version: string
): Promise<string> {
try {
const result = await packageRegistryView(packageName, version, 'version');
if (!result) {
throw new Error(`Unable to resolve version ${packageName}@${version}.`);
}
// get the last line of the output, strip the package version and quotes
const resolvedVersion = result
.split('\n')
.pop()
.split(' ')
.pop()
.replace(/'/g, '');
return resolvedVersion;
} catch {
throw new Error(`Unable to resolve version ${packageName}@${version}.`);
}
}
/**
* Return the resolved version for a given package and version tag using by
* installing it in a temporary directory and fetching the version from the
* package.json.
*/
export async function resolvePackageVersionUsingInstallation(
packageName: string,
version: string
): Promise<string> {
const { dir, cleanup } = createTempNpmDirectory();
try {
const pmc = getPackageManagerCommand();
await execAsync(`${pmc.add} ${packageName}@${version}`, { cwd: dir });
const { packageJson } = readModulePackageJson(packageName, [dir]);
return packageJson.version;
} finally {
await cleanup();
}
}
export async function packageRegistryView(
pkg: string,
version: string,
args: string
): Promise<string> {
let pm = detectPackageManager();
if (pm === 'yarn' || pm === 'bun') {
/**
* yarn has `yarn info` but it behaves differently than (p)npm,
* which makes it's usage unreliable
*
* @see https://github.com/nrwl/nx/pull/9667#discussion_r842553994
*/
pm = 'npm';
}
const { stdout } = await execAsync(`${pm} view ${pkg}@${version} ${args}`);
return stdout.toString().trim();
}
export async function packageRegistryPack(
cwd: string,
pkg: string,
version: string
): Promise<{ tarballPath: string }> {
let pm = detectPackageManager();
if (pm === 'yarn' || pm === 'bun') {
/**
* `(p)npm pack` will download a tarball of the specified version,
* whereas `yarn` pack creates a tarball of the active workspace, so it
* does not work for getting the content of a library.
*
* @see https://github.com/nrwl/nx/pull/9667#discussion_r842553994
*/
pm = 'npm';
}
const { stdout } = await execAsync(`${pm} pack ${pkg}@${version}`, { cwd });
const tarballPath = stdout.trim();
return { tarballPath };
}