forked from angular/angular-cli
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathpackage-manager.ts
More file actions
649 lines (571 loc) · 22.8 KB
/
package-manager.ts
File metadata and controls
649 lines (571 loc) · 22.8 KB
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
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
/**
* @fileoverview This file contains the `PackageManager` class, which is the
* core execution engine for all package manager commands. It is designed to be
* a flexible and secure abstraction over the various package managers.
*/
import { join, relative, resolve } from 'node:path';
import npa from 'npm-package-arg';
import { maxSatisfying, valid } from 'semver';
import { PackageManagerError } from './error';
import { Host } from './host';
import { Logger } from './logger';
import { PackageManagerDescriptor } from './package-manager-descriptor';
import { PackageManifest, PackageMetadata } from './package-metadata';
import { InstalledPackage } from './package-tree';
/**
* The fields to request from the registry for package metadata.
* This is a performance optimization to avoid downloading the full manifest
* when only summary data (like versions and tags) is needed.
*/
const METADATA_FIELDS = ['name', 'dist-tags', 'versions', 'time'] as const;
/**
* The fields to request from the registry for a package's manifest.
* This is a performance optimization to avoid downloading unnecessary data.
* These fields are the ones required by the CLI for operations like `ng add` and `ng update`.
*/
export const MANIFEST_FIELDS = [
'name',
'version',
'deprecated',
'dependencies',
'peerDependencies',
'devDependencies',
'homepage',
'schematics',
'ng-add',
'ng-update',
] as const;
/**
* Options to configure the `PackageManager` instance.
*/
export interface PackageManagerOptions {
/**
* If true, no commands will be executed, but they will be logged to the logger.
* A logger must be provided if this is true.
*/
dryRun?: boolean;
/** A logger instance for debugging and dry run output. */
logger?: Logger;
/**
* The base path to use for temporary directories.
*/
tempDirectory?: string;
/**
* The version of the package manager.
* If provided, the `getVersion` method will return this version
* instead of running the version command.
*/
version?: string;
/**
* An error that occurred during the initialization of the package manager.
* If provided, this error will be thrown when attempting to execute any command.
*/
initializationError?: Error;
}
/**
* A class that provides a high-level, package-manager-agnostic API for
* interacting with a project's dependencies.
*
* This class is an implementation of the Strategy design pattern. It is
* instantiated with a `PackageManagerDescriptor` that defines the specific
* commands and flags for a given package manager.
*/
export class PackageManager {
readonly #manifestCache = new Map<string, PackageManifest | null>();
readonly #metadataCache = new Map<string, PackageMetadata | null>();
readonly #initializationError?: Error;
#dependencyCache: Map<string, InstalledPackage> | null = null;
#version: string | undefined;
/**
* Creates a new `PackageManager` instance.
* @param host A `Host` instance for interacting with the file system and running commands.
* @param cwd The absolute path to the project's working directory.
* @param descriptor A `PackageManagerDescriptor` that defines the commands for a specific package manager.
* @param options An options object to configure the instance.
*/
constructor(
private readonly host: Host,
private readonly cwd: string,
private readonly descriptor: PackageManagerDescriptor,
private readonly options: PackageManagerOptions = {},
) {
if (this.options.dryRun && !this.options.logger) {
throw new Error('A logger must be provided when dryRun is enabled.');
}
this.#version = options.version;
this.#initializationError = options.initializationError;
}
/**
* The name of the package manager's binary.
*/
get name(): string {
return this.descriptor.binary;
}
/**
* Ensures that the package manager is installed and available in the PATH.
* If it is not, this method will throw an error with instructions on how to install it.
*
* @throws {Error} If the package manager is not installed.
*/
ensureInstalled(): void {
if (this.#initializationError) {
throw this.#initializationError;
}
}
/**
* A private method to lazily populate the dependency cache.
* This is a performance optimization to avoid running `npm list` multiple times.
* @returns A promise that resolves to the dependency cache map.
*/
async #populateDependencyCache(): Promise<Map<string, InstalledPackage>> {
if (this.#dependencyCache !== null) {
return this.#dependencyCache;
}
const args = this.descriptor.listDependenciesCommand;
const dependencies = await this.#fetchAndParse(args, (stdout, logger) =>
this.descriptor.outputParsers.listDependencies(stdout, logger),
);
return (this.#dependencyCache = dependencies ?? new Map());
}
/**
* A private method to run a command using the package manager's binary.
* @param args The arguments to pass to the command.
* @param options Options for the child process.
* @returns A promise that resolves with the standard output and standard error of the command.
*/
async #run(
args: readonly string[],
options: { timeout?: number; registry?: string; cwd?: string } = {},
): Promise<{ stdout: string; stderr: string }> {
this.ensureInstalled();
const { registry, cwd, ...runOptions } = options;
const finalArgs = [...args];
let finalEnv: Record<string, string> | undefined;
if (registry) {
const registryOptions = this.descriptor.getRegistryOptions?.(registry);
if (!registryOptions) {
throw new Error(
`The configured package manager, '${this.descriptor.binary}', does not support a custom registry.`,
);
}
if (registryOptions.args) {
finalArgs.push(...registryOptions.args);
}
if (registryOptions.env) {
finalEnv = registryOptions.env;
}
}
const executionDirectory = cwd ?? this.cwd;
if (this.options.dryRun) {
this.options.logger?.info(
`[DRY RUN] Would execute in [${executionDirectory}]: ${this.descriptor.binary} ${finalArgs.join(' ')}`,
);
return { stdout: '', stderr: '' };
}
const commandResult = await this.host.runCommand(this.descriptor.binary, finalArgs, {
...runOptions,
cwd: executionDirectory,
stdio: 'pipe',
env: finalEnv,
});
return { stdout: commandResult.stdout.trim(), stderr: commandResult.stderr.trim() };
}
/**
* A private, generic method to encapsulate the common logic of running a command,
* handling errors, and parsing the output.
* @param args The arguments to pass to the command.
* @param parser A function that parses the command's stdout.
* @param options Options for the command, including caching.
* @returns A promise that resolves to the parsed data, or null if not found.
*/
async #fetchAndParse<T>(
args: readonly string[],
parser: (stdout: string, logger?: Logger) => T | null,
options: {
timeout?: number;
registry?: string;
bypassCache?: boolean;
cache?: Map<string, T | null>;
cacheKey?: string;
} = {},
): Promise<T | null> {
const { cache, cacheKey, bypassCache, ...runOptions } = options;
if (!bypassCache && cache && cacheKey && cache.has(cacheKey)) {
return cache.get(cacheKey) as T | null;
}
let stdout;
let stderr;
let exitCode;
let thrownError;
try {
({ stdout, stderr } = await this.#run(args, runOptions));
exitCode = 0;
} catch (e) {
thrownError = e;
if (e instanceof PackageManagerError) {
stdout = e.stdout;
stderr = e.stderr;
exitCode = e.exitCode;
} else {
// Re-throw unexpected errors
throw e;
}
}
// Yarn classic can exit with code 0 even when an error occurs.
// To ensure we capture these cases, we will always attempt to parse a
// structured error from the output, regardless of the exit code.
const getError = this.descriptor.outputParsers.getError;
const parsedError =
getError?.(stdout, this.options.logger) ?? getError?.(stderr, this.options.logger) ?? null;
if (parsedError) {
this.options.logger?.debug(
`[${this.descriptor.binary}] Structured error (code: ${parsedError.code}): ${parsedError.summary}`,
);
// Special case for 'not found' errors (e.g., E404). Return null for these.
if (this.descriptor.isNotFound(parsedError)) {
if (cache && cacheKey) {
cache.set(cacheKey, null);
}
return null;
} else {
// For all other structured errors, throw a more informative error.
throw new PackageManagerError(parsedError.summary, stdout, stderr, exitCode);
}
}
// If an error was originally thrown and we didn't parse a more specific
// structured error, re-throw the original error now.
if (thrownError) {
throw thrownError;
}
// If we reach this point, the command succeeded and no structured error was found.
// We can now safely parse the successful output.
try {
const result = parser(stdout, this.options.logger);
if (cache && cacheKey) {
cache.set(cacheKey, result);
}
return result;
} catch (e) {
const message = `Failed to parse package manager output: ${
e instanceof Error ? e.message : ''
}`;
throw new PackageManagerError(message, stdout, stderr, exitCode);
}
}
/**
* Adds a package to the project's dependencies.
* @param packageName The name of the package to add.
* @param save The save strategy to use.
* - `exact`: The package will be saved with an exact version.
* - `tilde`: The package will be saved with a tilde version range (`~`).
* - `none`: The package will be saved with the default version range (`^`).
* @param asDevDependency Whether to install the package as a dev dependency.
* @param noLockfile Whether to skip updating the lockfile.
* @param options Extra options for the command.
* @returns A promise that resolves when the command is complete.
*/
async add(
packageName: string,
save: 'exact' | 'tilde' | 'none',
asDevDependency: boolean,
noLockfile: boolean,
ignoreScripts: boolean,
options: { registry?: string } = {},
): Promise<void> {
const flags = [
asDevDependency ? this.descriptor.saveDevFlag : '',
save === 'exact' ? this.descriptor.saveExactFlag : '',
save === 'tilde' ? this.descriptor.saveTildeFlag : '',
noLockfile ? this.descriptor.noLockfileFlag : '',
ignoreScripts ? this.descriptor.ignoreScriptsFlag : '',
].filter((flag) => flag);
const args = [this.descriptor.addCommand, packageName, ...flags];
await this.#run(args, options);
this.#dependencyCache = null;
}
/**
* Installs all dependencies in the project.
* @param options Options for the installation.
* @param options.timeout The maximum time in milliseconds to wait for the command to complete.
* @param options.force If true, forces a clean install, potentially overwriting existing modules.
* @param options.registry The registry to use for the installation.
* @param options.ignoreScripts If true, prevents lifecycle scripts from being executed.
* @returns A promise that resolves when the command is complete.
*/
async install(
options: {
timeout?: number;
force?: boolean;
registry?: string;
ignoreScripts?: boolean;
ignorePeerDependencies?: boolean;
} = { ignoreScripts: true },
): Promise<void> {
const flags = [
options.force ? this.descriptor.forceFlag : '',
options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : '',
options.ignorePeerDependencies ? (this.descriptor.ignorePeerDependenciesFlag ?? '') : '',
].filter((flag) => flag);
const args = [...this.descriptor.installCommand, ...flags];
await this.#run(args, options);
this.#dependencyCache = null;
}
/**
* Gets the version of the package manager binary.
*/
async getVersion(): Promise<string> {
if (this.#version) {
return this.#version;
}
const { stdout } = await this.#run(this.descriptor.versionCommand);
this.#version = stdout.trim();
if (!valid(this.#version)) {
throw new Error(`Invalid semver version for ${this.name}: "${this.#version}"`);
}
return this.#version;
}
/**
* Gets the installed details of a package from the project's dependencies.
* @param packageName The name of the package to check.
* @returns A promise that resolves to the installed package details, or `null` if the package is not installed.
*/
async getInstalledPackage(packageName: string): Promise<InstalledPackage | null> {
const cache = await this.#populateDependencyCache();
return cache.get(packageName) ?? null;
}
/**
* Gets a map of all top-level dependencies installed in the project.
* @returns A promise that resolves to a map of package names to their installed package details.
*/
async getProjectDependencies(): Promise<Map<string, InstalledPackage>> {
const cache = await this.#populateDependencyCache();
// Return a copy to prevent external mutations of the cache.
return new Map(cache);
}
/**
* Fetches the registry metadata for a package. This is the full metadata,
* including all versions and distribution tags.
* @param packageName The name of the package to fetch the metadata for.
* @param options Options for the fetch.
* @param options.timeout The maximum time in milliseconds to wait for the command to complete.
* @param options.registry The registry to use for the fetch.
* @param options.bypassCache If true, ignores the in-memory cache and fetches fresh data.
* @returns A promise that resolves to the `PackageMetadata` object, or `null` if the package is not found.
*/
async getRegistryMetadata(
packageName: string,
options: { timeout?: number; registry?: string; bypassCache?: boolean } = {},
): Promise<PackageMetadata | null> {
const commandArgs = [...this.descriptor.getManifestCommand, packageName];
const formatter = this.descriptor.viewCommandFieldArgFormatter;
if (formatter) {
commandArgs.push(...formatter(METADATA_FIELDS));
}
const cacheKey = options.registry ? `${packageName}|${options.registry}` : packageName;
return this.#fetchAndParse(
commandArgs,
(stdout, logger) => this.descriptor.outputParsers.getRegistryMetadata(stdout, logger),
{ ...options, cache: this.#metadataCache, cacheKey },
);
}
/**
* Fetches the registry manifest for a specific version of a package.
* The manifest is similar to the package's `package.json` file.
* @param packageName The name of the package to fetch the manifest for.
* @param version The version of the package to fetch the manifest for.
* @param options Options for the fetch.
* @param options.timeout The maximum time in milliseconds to wait for the command to complete.
* @param options.registry The registry to use for the fetch.
* @param options.bypassCache If true, ignores the in-memory cache and fetches fresh data.
* @returns A promise that resolves to the `PackageManifest` object, or `null` if the package is not found.
*/
async getRegistryManifest(
packageName: string,
version: string,
options: { timeout?: number; registry?: string; bypassCache?: boolean } = {},
): Promise<PackageManifest | null> {
const specifier = this.host.requiresQuoting
? `"${packageName}@${version}"`
: `${packageName}@${version}`;
const commandArgs = [...this.descriptor.getManifestCommand, specifier];
const formatter = this.descriptor.viewCommandFieldArgFormatter;
if (formatter) {
commandArgs.push(...formatter(MANIFEST_FIELDS));
}
const cacheKey = options.registry ? `${specifier}|${options.registry}` : specifier;
const manifest = await this.#fetchAndParse(
commandArgs,
(stdout, logger) => this.descriptor.outputParsers.getRegistryManifest(stdout, logger),
{ ...options, cache: this.#manifestCache, cacheKey },
);
// If the provided version was not a specific version, also cache the specific fetched version
if (manifest && manifest.version !== version) {
const manifestSpecifier = `${manifest.name}@${manifest.version}`;
const manifestCacheKey = options.registry
? `${manifestSpecifier}|${options.registry}`
: manifestSpecifier;
this.#manifestCache.set(manifestCacheKey, manifest);
}
return manifest;
}
/**
* Fetches the manifest for a package.
*
* This method can resolve manifests for packages from the registry, as well
* as those specified by file paths, directory paths, and remote tarballs.
* Caching is only supported for registry packages.
*
* @param specifier The package specifier to resolve the manifest for.
* @param options Options for the fetch.
* @returns A promise that resolves to the `PackageManifest` object, or `null` if the package is not found.
*/
async getManifest(
specifier: string | npa.Result,
options: { timeout?: number; registry?: string; bypassCache?: boolean } = {},
): Promise<PackageManifest | null> {
const { name, type, fetchSpec } = typeof specifier === 'string' ? npa(specifier) : specifier;
switch (type) {
case 'range':
case 'version':
case 'tag': {
if (!name) {
throw new Error(`Could not parse package name from specifier: ${specifier}`);
}
// `fetchSpec` is the version, range, or tag.
let versionSpec = fetchSpec ?? 'latest';
if (this.descriptor.requiresManifestVersionLookup) {
if (type === 'tag' || !fetchSpec) {
const metadata = await this.getRegistryMetadata(name, options);
if (!metadata) {
return null;
}
versionSpec = metadata['dist-tags'][versionSpec];
} else if (type === 'range') {
const metadata = await this.getRegistryMetadata(name, options);
if (!metadata) {
return null;
}
versionSpec = maxSatisfying(metadata.versions, fetchSpec) ?? '';
}
if (!versionSpec) {
return null;
}
}
return this.getRegistryManifest(name, versionSpec, options);
}
case 'directory': {
if (!fetchSpec) {
throw new Error(`Could not parse directory path from specifier: ${specifier}`);
}
const manifestPath = join(fetchSpec, 'package.json');
const manifest = await this.host.readFile(manifestPath);
return JSON.parse(manifest);
}
case 'file':
case 'remote':
case 'git': {
if (!fetchSpec) {
throw new Error(`Could not parse location from specifier: ${specifier}`);
}
// Caching is not supported for non-registry specifiers.
const { workingDirectory, cleanup } = await this.acquireTempPackage(fetchSpec, {
...options,
ignoreScripts: true,
});
try {
// Discover the package name by reading the temporary `package.json` file.
// The package manager will have added the package to the `dependencies`.
const tempManifest = await this.host.readFile(join(workingDirectory, 'package.json'));
const { dependencies } = JSON.parse(tempManifest) as PackageManifest;
const packageName = dependencies && Object.keys(dependencies)[0];
if (!packageName) {
throw new Error(`Could not determine package name for specifier: ${specifier}`);
}
// The package will be installed in `<temp>/node_modules/<name>`.
const packagePath = join(workingDirectory, 'node_modules', packageName);
const manifestPath = join(packagePath, 'package.json');
const manifest = await this.host.readFile(manifestPath);
return JSON.parse(manifest);
} finally {
await cleanup();
}
}
default:
throw new Error(`Unsupported package specifier type: ${type}`);
}
}
private async getTemporaryDirectory(): Promise<string | undefined> {
const { tempDirectory } = this.options;
if (tempDirectory && !relative(this.cwd, tempDirectory).startsWith('..')) {
try {
await this.host.stat(tempDirectory);
} catch {
// If the cache directory doesn't exist, create it.
await this.host.mkdir(tempDirectory, { recursive: true });
}
return tempDirectory;
}
const tempOptions = ['node_modules'];
for (const tempOption of tempOptions) {
try {
const directory = resolve(this.cwd, tempOption);
if ((await this.host.stat(directory)).isDirectory()) {
return directory;
}
} catch {}
}
}
/**
* Acquires a package by installing it into a temporary directory. The caller is
* responsible for managing the lifecycle of the temporary directory by calling
* the returned `cleanup` function.
*
* @param specifier The specifier of the package to install.
* @param options Options for the installation.
* @returns A promise that resolves to an object containing the temporary path
* and a cleanup function.
*/
async acquireTempPackage(
specifier: string,
options: { registry?: string; ignoreScripts?: boolean } = {},
): Promise<{ workingDirectory: string; cleanup: () => Promise<void> }> {
const workingDirectory = await this.host.createTempDirectory(
await this.getTemporaryDirectory(),
);
const cleanup = () => this.host.deleteDirectory(workingDirectory);
// Some package managers, like yarn classic, do not write a package.json when adding a package.
// This can cause issues with subsequent `require.resolve` calls.
// Writing an empty package.json file beforehand prevents this.
await this.host.writeFile(join(workingDirectory, 'package.json'), '{}');
// Copy configuration files if the package manager requires it (e.g., bun).
if (this.descriptor.copyConfigFromProject) {
for (const configFile of this.descriptor.configFiles) {
try {
const configPath = join(this.cwd, configFile);
await this.host.copyFile(configPath, join(workingDirectory, configFile));
} catch {
// Ignore missing config files.
}
}
}
const flags = [options.ignoreScripts ? this.descriptor.ignoreScriptsFlag : ''].filter(
(flag) => flag,
);
const args: readonly string[] = [this.descriptor.addCommand, specifier, ...flags];
try {
await this.#run(args, { ...options, cwd: workingDirectory });
} catch (e) {
// If the command fails, clean up the temporary directory immediately.
await cleanup();
throw e;
}
return { workingDirectory, cleanup };
}
}