diff --git a/packages/angular/cli/src/package-managers/error.ts b/packages/angular/cli/src/package-managers/error.ts index c17af3f7cae3..1ce79f2240a7 100644 --- a/packages/angular/cli/src/package-managers/error.ts +++ b/packages/angular/cli/src/package-managers/error.ts @@ -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; + + /** A short, human-readable summary of the error. */ + readonly summary: string; + + /** An optional, detailed description of the error. */ + readonly detail?: string; +} diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index 85fa55707a11..322cf5aa2147 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -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, @@ -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 @@ -128,7 +149,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = { listDependencies: parseNpmLikeDependencies, getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, + getError: parseNpmLikeError, }, + isNotFound: isKnownNotFound, }, yarn: { binary: 'yarn', @@ -150,7 +173,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = { listDependencies: parseYarnModernDependencies, getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, + getError: parseNpmLikeError, }, + isNotFound: isKnownNotFound, }, 'yarn-classic': { binary: 'yarn', @@ -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', @@ -197,7 +224,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = { listDependencies: parseNpmLikeDependencies, getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, + getError: parseNpmLikeError, }, + isNotFound: isKnownNotFound, }, bun: { binary: 'bun', @@ -219,7 +248,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = { listDependencies: parseNpmLikeDependencies, getRegistryManifest: parseNpmLikeManifest, getRegistryMetadata: parseNpmLikeMetadata, + getError: parseNpmLikeError, }, + isNotFound: isKnownNotFound, }, } satisfies Record; diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 85d532850938..7a012a3e544c 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -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'; @@ -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) { @@ -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); } } diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index fd52402f1cf3..0e12fd5f0cfb 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -12,6 +12,7 @@ * into their own file improves modularity and allows for focused testing. */ +import { ErrorInfo } from './error'; import { Logger } from './logger'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; @@ -31,6 +32,26 @@ function logStdout(stdout: string, logger?: Logger): void { logger.debug(` stdout:\n${output}`); } +/** + * A generator function that parses a string containing JSONL (newline-delimited JSON) + * and yields each successfully parsed JSON object. + * @param output The string output to parse. + * @param logger An optional logger instance. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function* parseJsonLines(output: string, logger?: Logger): Generator { + for (const line of output.split('\n')) { + if (!line.trim()) { + continue; + } + try { + yield JSON.parse(line); + } catch (e) { + logger?.debug(` Ignoring non-JSON line: ${e}`); + } + } +} + interface NpmListDependency { version: string; path?: string; @@ -106,7 +127,7 @@ export function parseNpmLikeDependencies( * Parses the output of `yarn list` (classic). * * The expected output is a JSON stream (JSONL), where each line is a JSON object. - * The relevant object has a `type` of `'tree'`. + * The relevant object has a `type` of `'tree'` with a `data` property. * Yarn classic does not provide a path, so the `path` property will be `undefined`. * * ```json @@ -131,11 +152,7 @@ export function parseYarnClassicDependencies( return dependencies; } - for (const line of stdout.split('\n')) { - if (!line) { - continue; - } - const json = JSON.parse(line); + for (const json of parseJsonLines(stdout, logger)) { if (json.type === 'tree' && json.data?.trees) { for (const info of json.data.trees) { const name = info.name.split('@')[0]; @@ -202,25 +219,16 @@ export function parseYarnModernDependencies( ` Failed to parse as single JSON object: ${e}. Falling back to line-by-line parsing.`, ); // Fallback for older versions of yarn berry that might still output json lines - for (const line of stdout.split('\n')) { - if (!line) { - continue; - } - try { - const json = JSON.parse(line); - if (json.type === 'tree' && json.data?.trees) { - for (const info of json.data.trees) { - const name = info.name.split('@')[0]; - const version = info.name.split('@').pop(); - dependencies.set(name, { - name, - version, - }); - } + for (const json of parseJsonLines(stdout, logger)) { + if (json.type === 'tree' && json.data?.trees) { + for (const info of json.data.trees) { + const name = info.name.split('@')[0]; + const version = info.name.split('@').pop(); + dependencies.set(name, { + name, + version, + }); } - } catch (innerError) { - logger?.debug(` Ignoring non-JSON line: ${innerError}`); - // Ignore lines that are not valid JSON. } } } @@ -270,9 +278,16 @@ export function parseNpmLikeMetadata(stdout: string, logger?: Logger): PackageMe /** * Parses the output of `yarn info` (classic) to get a package manifest. + * + * When `yarn info --verbose` is used, the output is a JSONL stream. This function + * iterates through the lines to find the object with `type: 'inspect'` which contains + * the package manifest. + * + * For non-verbose output, it falls back to parsing a single JSON object. + * * @param stdout The standard output of the command. * @param logger An optional logger instance. - * @returns The package manifest object. + * @returns The package manifest object, or `null` if not found. */ export function parseYarnClassicManifest(stdout: string, logger?: Logger): PackageManifest | null { logger?.debug(`Parsing yarn classic manifest...`); @@ -284,10 +299,21 @@ export function parseYarnClassicManifest(stdout: string, logger?: Logger): Packa return null; } - const data = JSON.parse(stdout); + // Yarn classic outputs JSONL. We need to find the relevant object. + let manifest; + for (const json of parseJsonLines(stdout, logger)) { + // The manifest data is in a JSON object with type 'inspect'. + if (json.type === 'inspect' && json.data) { + manifest = json.data; + break; + } + } - // Yarn classic wraps the manifest in a `data` property. - const manifest = data.data as PackageManifest; + if (!manifest) { + logger?.debug(' Failed to find manifest in yarn classic output.'); + + return null; + } // Yarn classic removes any field with a falsy value // https://github.com/yarnpkg/yarn/blob/7cafa512a777048ce0b666080a24e80aae3d66a9/src/cli/commands/info.js#L26-L29 @@ -322,8 +348,174 @@ export function parseYarnClassicMetadata(stdout: string, logger?: Logger): Packa return null; } - const data = JSON.parse(stdout); + // Yarn classic outputs JSONL. We need to find the relevant object. + let metadata; + for (const json of parseJsonLines(stdout, logger)) { + // The metadata data is in a JSON object with type 'inspect'. + if (json.type === 'inspect' && json.data) { + metadata = json.data; + break; + } + } + + if (!metadata) { + logger?.debug(' Failed to find metadata in yarn classic output.'); + + return null; + } + + return metadata; +} + +/** + * Parses the `stdout` or `stderr` output of npm, pnpm, modern yarn, or bun to extract structured error information. + * + * This parser uses a multi-stage approach. It first attempts to parse the entire `output` as a + * single JSON object, which is the standard for modern tools like pnpm, yarn, and bun. If JSON + * parsing fails, it falls back to a line-by-line regex-based approach to handle the plain + * text output from older versions of npm. + * + * Example JSON output (pnpm): + * ```json + * { + * "code": "E404", + * "summary": "Not Found - GET https://registry.npmjs.org/@angular%2fnon-existent - Not found", + * "detail": "The requested resource '@angular/non-existent@*' could not be found or you do not have permission to access it." + * } + * ``` + * + * Example text output (npm): + * ``` + * npm error code E404 + * npm error 404 Not Found - GET https://registry.npmjs.org/@angular%2fnon-existent - Not found + * ``` + * + * @param output The standard output or standard error of the command. + * @param logger An optional logger instance. + * @returns An `ErrorInfo` object if parsing is successful, otherwise `null`. + */ +export function parseNpmLikeError(output: string, logger?: Logger): ErrorInfo | null { + logger?.debug(`Parsing npm-like error output...`); + logStdout(output, logger); // Log output for debugging purposes + + if (!output) { + logger?.debug(' output is empty. No error found.'); + + return null; + } + + // Attempt to parse as JSON first (common for pnpm, modern yarn, bun) + try { + const jsonError = JSON.parse(output); + if ( + jsonError && + typeof jsonError.code === 'string' && + (typeof jsonError.summary === 'string' || typeof jsonError.message === 'string') + ) { + const summary = jsonError.summary || jsonError.message; + logger?.debug(` Successfully parsed JSON error with code '${jsonError.code}'.`); + + return { + code: jsonError.code, + summary, + detail: jsonError.detail, + }; + } + } catch (e) { + logger?.debug(` Failed to parse output as JSON: ${e}. Attempting regex fallback.`); + // Fallback to regex for plain text errors (common for npm) + } + + // Regex for npm-like error codes (e.g., `npm ERR! code E404` or `npm error code E404`) + const errorCodeMatch = output.match(/npm (ERR!|error) code (E\d{3}|[A-Z_]+)/); + if (errorCodeMatch) { + const code = errorCodeMatch[2]; // Capture group 2 is the actual error code + let summary: string | undefined; + + // Find the most descriptive summary line (the line after `npm ERR! code ...` or `npm error code ...`). + for (const line of output.split('\n')) { + if (line.startsWith('npm ERR!') && !line.includes(' code ')) { + summary = line.replace('npm ERR! ', '').trim(); + break; + } else if (line.startsWith('npm error') && !line.includes(' code ')) { + summary = line.replace('npm error ', '').trim(); + break; + } + } + + logger?.debug(` Successfully parsed text error with code '${code}'.`); + + return { + code, + summary: summary || `Package manager error: ${code}`, + }; + } + + logger?.debug(' Failed to parse npm-like error. No structured error found.'); + + return null; +} + +/** + * Parses the `stdout` or `stderr` output of yarn classic to extract structured error information. + * + * This parser first attempts to find an HTTP status code (e.g., 404, 401) in the verbose output. + * If found, it returns a standardized error code (`E${statusCode}`). + * If no HTTP status code is found, it falls back to parsing generic JSON error lines. + * + * Example verbose output (with HTTP status code): + * ```json + * {"type":"verbose","data":"Request \"https://registry.npmjs.org/@angular%2fnon-existent\" finished with status code 404."} + * ``` + * + * Example generic JSON error output: + * ```json + * {"type":"error","data":"Received invalid response from npm."} + * ``` + * + * @param output The standard output or standard error of the command. + * @param logger An optional logger instance. + * @returns An `ErrorInfo` object if parsing is successful, otherwise `null`. + */ +export function parseYarnClassicError(output: string, logger?: Logger): ErrorInfo | null { + logger?.debug(`Parsing yarn classic error output...`); + logStdout(output, logger); // Log output for debugging purposes + + if (!output) { + logger?.debug(' output is empty. No error found.'); + + return null; + } + + // First, check for any HTTP status code in the verbose output. + const statusCodeMatch = output.match(/finished with status code (\d{3})/); + if (statusCodeMatch) { + const statusCode = Number(statusCodeMatch[1]); + // Status codes in the 200-299 range are successful. + if (statusCode < 200 || statusCode >= 300) { + logger?.debug(` Detected HTTP error status code '${statusCode}' in verbose output.`); + + return { + code: `E${statusCode}`, + summary: `Request failed with status code ${statusCode}.`, + }; + } + } + + // Fallback to the JSON error type if no HTTP status code is present. + for (const json of parseJsonLines(output, logger)) { + if (json.type === 'error' && typeof json.data === 'string') { + const summary = json.data; + logger?.debug(` Successfully parsed generic yarn classic error.`); + + return { + code: 'UNKNOWN_ERROR', + summary, + }; + } + } + + logger?.debug(' Failed to parse yarn classic error. No structured error found.'); - // Yarn classic wraps the metadata in a `data` property. - return data.data; + return null; } diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts new file mode 100644 index 000000000000..8717a6d1a5a1 --- /dev/null +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -0,0 +1,112 @@ +/** + * @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 + */ + +import { parseNpmLikeError, parseYarnClassicError } from './parsers'; + +describe('parsers', () => { + describe('parseNpmLikeError', () => { + it('should parse a structured JSON error from modern yarn', () => { + const stdout = JSON.stringify({ + code: 'ERR_PNPM_NO_SUCH_PACKAGE', + summary: 'No such package.', + detail: 'Package not found.', + }); + const error = parseNpmLikeError(stdout); + expect(error).toEqual({ + code: 'ERR_PNPM_NO_SUCH_PACKAGE', + summary: 'No such package.', + detail: 'Package not found.', + }); + }); + + it('should parse a plain text error from npm', () => { + const stdout = + 'npm error code E404\nnpm error 404 Not Found - GET https://registry.example.com/non-existent-package'; + const error = parseNpmLikeError(stdout); + expect(error).toEqual({ + code: 'E404', + summary: '404 Not Found - GET https://registry.example.com/non-existent-package', + }); + }); + + it('should parse a plain text error from npm with ERR!', () => { + const stderr = + 'npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://registry.example.com/non-existent-package'; + const error = parseNpmLikeError(stderr); + expect(error).toEqual({ + code: 'E404', + summary: '404 Not Found - GET https://registry.example.com/non-existent-package', + }); + }); + + it('should parse a structured JSON error with a message property', () => { + const stderr = JSON.stringify({ + code: 'EUNSUPPORTEDPROTOCOL', + message: 'Unsupported protocol.', + detail: 'The protocol "invalid:" is not supported.', + }); + const error = parseNpmLikeError(stderr); + expect(error).toEqual({ + code: 'EUNSUPPORTEDPROTOCOL', + summary: 'Unsupported protocol.', + detail: 'The protocol "invalid:" is not supported.', + }); + }); + + it('should return null for empty stdout', () => { + const error = parseNpmLikeError(''); + expect(error).toBeNull(); + }); + + it('should return null for unparsable stdout', () => { + const error = parseNpmLikeError('An unexpected error occurred.'); + expect(error).toBeNull(); + }); + }); + + describe('parseYarnClassicError', () => { + it('should parse a 404 from verbose logs', () => { + const stdout = + '{"type":"verbose","data":"Request "https://registry.example.com/non-existent-package" finished with status code 404."}'; + const error = parseYarnClassicError(stdout); + expect(error).toEqual({ + code: 'E404', + summary: 'Request failed with status code 404.', + }); + }); + + it('should parse a non-404 HTTP error from verbose logs', () => { + const stdout = + '{"type":"verbose","data":"Request "https://registry.example.com/private-package" finished with status code 401."}'; + const error = parseYarnClassicError(stdout); + expect(error).toEqual({ + code: 'E401', + summary: 'Request failed with status code 401.', + }); + }); + + it('should parse a generic JSON error when no HTTP status is found', () => { + const stdout = '{"type":"error","data":"An unexpected error occurred."}'; + const error = parseYarnClassicError(stdout); + expect(error).toEqual({ + code: 'UNKNOWN_ERROR', + summary: 'An unexpected error occurred.', + }); + }); + + it('should return null for empty stdout', () => { + const error = parseYarnClassicError(''); + expect(error).toBeNull(); + }); + + it('should return null for unparsable stdout', () => { + const error = parseYarnClassicError('A random error message.'); + expect(error).toBeNull(); + }); + }); +});