Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/angular/cli/src/package-managers/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,18 @@ export class PackageManagerError extends Error {
super(message);
}
}

/**
* Represents structured information about an error returned by a package manager command.
* This is a data interface, not an `Error` subclass.
*/
export interface ErrorInfo {
/** A specific error code (e.g. 'E404', 'EACCES'). */
readonly code: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth to make this narrower than string to make it clearer what kind of error codes to expect?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't seem to be particularly consistent, unfortunately. Sometimes its a number, an HTTP status code, a Node.js error code, or even a package manager specific one.


/** A short, human-readable summary of the error. */
readonly summary: string;

/** An optional, detailed description of the error. */
readonly detail?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@
* package-manager-specific commands, flags, and output parsing.
*/

import { ErrorInfo } from './error';
import { Logger } from './logger';
import { PackageManifest, PackageMetadata } from './package-metadata';
import { InstalledPackage } from './package-tree';
import {
parseNpmLikeDependencies,
parseNpmLikeError,
parseNpmLikeManifest,
parseNpmLikeMetadata,
parseYarnClassicDependencies,
parseYarnClassicError,
parseYarnClassicManifest,
parseYarnClassicMetadata,
parseYarnModernDependencies,
Expand Down Expand Up @@ -90,12 +93,30 @@ export interface PackageManagerDescriptor {

/** A function to parse the output of `getManifestCommand` for the full package metadata. */
getRegistryMetadata: (stdout: string, logger?: Logger) => PackageMetadata | null;

/** A function to parse the output when a command fails. */
getError?: (output: string, logger?: Logger) => ErrorInfo | null;
};

/** A function that checks if a structured error represents a "package not found" error. */
readonly isNotFound: (error: ErrorInfo) => boolean;
}

/** A type that represents the name of a supported package manager. */
export type PackageManagerName = keyof typeof SUPPORTED_PACKAGE_MANAGERS;

/** A set of error codes that are known to indicate a "package not found" error. */
const NOT_FOUND_ERROR_CODES = new Set(['E404']);

/**
* A shared function to check if a structured error represents a "package not found" error.
* @param error The structured error to check.
* @returns True if the error code is a known "not found" code, false otherwise.
*/
function isKnownNotFound(error: ErrorInfo): boolean {
return NOT_FOUND_ERROR_CODES.has(error.code);
}

/**
* A map of supported package managers to their descriptors.
* This is the single source of truth for all package-manager-specific
Expand Down Expand Up @@ -128,7 +149,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
listDependencies: parseNpmLikeDependencies,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseNpmLikeError,
},
isNotFound: isKnownNotFound,
},
yarn: {
binary: 'yarn',
Expand All @@ -150,7 +173,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
listDependencies: parseYarnModernDependencies,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseNpmLikeError,
},
isNotFound: isKnownNotFound,
},
'yarn-classic': {
binary: 'yarn',
Expand All @@ -169,13 +194,15 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
getRegistryOptions: (registry: string) => ({ args: ['--registry', registry] }),
versionCommand: ['--version'],
listDependenciesCommand: ['list', '--depth=0', '--json'],
getManifestCommand: ['info', '--json'],
getManifestCommand: ['info', '--json', '--verbose'],
requiresManifestVersionLookup: true,
outputParsers: {
listDependencies: parseYarnClassicDependencies,
getRegistryManifest: parseYarnClassicManifest,
getRegistryMetadata: parseYarnClassicMetadata,
getError: parseYarnClassicError,
},
isNotFound: isKnownNotFound,
},
pnpm: {
binary: 'pnpm',
Expand All @@ -197,7 +224,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
listDependencies: parseNpmLikeDependencies,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseNpmLikeError,
},
isNotFound: isKnownNotFound,
},
bun: {
binary: 'bun',
Expand All @@ -219,7 +248,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
listDependencies: parseNpmLikeDependencies,
getRegistryManifest: parseNpmLikeManifest,
getRegistryMetadata: parseNpmLikeMetadata,
getError: parseNpmLikeError,
},
isNotFound: isKnownNotFound,
},
} satisfies Record<string, PackageManagerDescriptor>;

Expand Down
46 changes: 41 additions & 5 deletions packages/angular/cli/src/package-managers/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import { join } from 'node:path';
import npa from 'npm-package-arg';
import { maxSatisfying } from 'semver';
import { PackageManagerError } from './error';
import { ErrorInfo, PackageManagerError } from './error';
import { Host } from './host';
import { Logger } from './logger';
import { PackageManagerDescriptor } from './package-manager-descriptor';
Expand Down Expand Up @@ -194,20 +194,56 @@ export class PackageManager {

let stdout;
let stderr;
let exitCode;
let thrownError;
try {
({ stdout, stderr } = await this.#run(args, runOptions));
exitCode = 0;
} catch (e) {
if (e instanceof PackageManagerError && typeof e.exitCode === 'number' && e.exitCode !== 0) {
// Some package managers exit with a non-zero code when the package is not found.
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);
}
throw e;
}

// 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) {
Expand All @@ -219,7 +255,7 @@ export class PackageManager {
const message = `Failed to parse package manager output: ${
e instanceof Error ? e.message : ''
}`;
throw new PackageManagerError(message, stdout, stderr, 0);
throw new PackageManagerError(message, stdout, stderr, exitCode);
}
}

Expand Down
Loading