/
githubmirror
/
angular-cli
Обзор
Документация
Войти
/
githubmirror
/
angular-cli
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/angular/cli/src/package-managers/parsers.ts
844 строки
25 KB
Alan Agius
fix(@angular/cli): support npm 12 metadata array and error formats
30 июл 2026, 10:46
30 июл 2026, 10:46
e148a8d
Код
Авторство
О чём код?
/** * @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 parser functions that are used to * interpret the output of various package manager commands. Separating these * into their own file improves modularity and allows for focused testing. */ import { compare, valid } from 'semver'; import { ErrorInfo } from './error'; import { Logger } from './logger'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; const MAX_LOG_LENGTH = 1024; function logStdout(stdout: string, logger?: Logger): void { if (!logger) { return; } let output = stdout; if (output.length > MAX_LOG_LENGTH) { output = `${output.slice(0, MAX_LOG_LENGTH)}... (truncated)`; } 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<any> { 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; [key: string]: unknown; } /** * Parses the output of `npm list` or a compatible command. * * The expected JSON structure is: * ```json * { * "dependencies": { * "@angular/cli": { * "version": "18.0.0", * "path": "/path/to/project/node_modules/@angular/cli", // path is optional * ... (other package.json properties) * } * } * } * ``` * * @param stdout The standard output of the command. * @param logger An optional logger instance. * @returns A map of package names to their installed package details. */ interface NpmListProject { name?: string; dependencies?: Record<string, NpmListDependency>; devDependencies?: Record<string, NpmListDependency>; unsavedDependencies?: Record<string, NpmListDependency>; } export function parseNpmLikeDependencies( stdout: string, logger?: Logger, options?: { workspacePackageName?: string }, ): Map<string, InstalledPackage> { logger?.debug(`Parsing npm-like dependency list...`); logStdout(stdout, logger); const dependencies = new Map<string, InstalledPackage>(); if (!stdout) { logger?.debug(' stdout is empty. No dependencies found.'); return dependencies; } const data = JSON.parse(stdout); const workspacePackageName = options?.workspacePackageName; let rootProject: NpmListProject | undefined; let subProject: NpmListProject | undefined; if (Array.isArray(data)) { rootProject = data[0]; if (workspacePackageName) { subProject = data.find( (project) => (project as NpmListProject | undefined)?.name === workspacePackageName, ); } } else { rootProject = data; } if (!rootProject || typeof rootProject !== 'object') { return dependencies; } // 1. Extract subproject dependencies first to prioritize them if (subProject) { const subprojectMaps = [ subProject.dependencies, subProject.devDependencies, subProject.unsavedDependencies, ].filter((d) => !!d) as Record<string, NpmListDependency>[]; for (const subprojectMap of subprojectMaps) { for (const [name, info] of Object.entries(subprojectMap)) { if (info && typeof info === 'object' && info.version) { dependencies.set(name, { name, version: info.version, path: info.path, }); } } } } else if (workspacePackageName) { // Fallback to npm-like nested lookup inside the root project const rootDependencyMaps = [ rootProject.dependencies, rootProject.devDependencies, rootProject.unsavedDependencies, ].filter((d) => !!d) as Record<string, NpmListDependency>[]; for (const dependencyMap of rootDependencyMaps) { const info = dependencyMap[workspacePackageName]; if (info && typeof info === 'object') { const nestedMaps = [ info.dependencies, info.devDependencies, info.unsavedDependencies, ].filter((d) => !!d) as Record<string, NpmListDependency>[]; for (const nestedMap of nestedMaps) { for (const [name, nestedInfo] of Object.entries(nestedMap)) { if (nestedInfo && typeof nestedInfo === 'object' && nestedInfo.version) { dependencies.set(name, { name, version: nestedInfo.version, path: nestedInfo.path, }); } } } } } } // 2. Supplement with hoisted/root dependencies (without overwriting subproject dependencies) const rootDependencyMaps = [ rootProject.dependencies, rootProject.devDependencies, rootProject.unsavedDependencies, ].filter((d) => !!d) as Record<string, NpmListDependency & { resolved?: string }>[]; for (const dependencyMap of rootDependencyMaps) { for (const [name, info] of Object.entries(dependencyMap)) { if (!info || typeof info !== 'object') { continue; } // Exclude local monorepo workspace packages (which originate from a local file/dir // and contain nested dependency maps in the output of `npm list --depth=0`), // while preserving third-party packages installed from local paths. const isWorkspacePackage = info.resolved?.startsWith('file:') && (!!info.dependencies || !!info.devDependencies || !!info.unsavedDependencies); if (info.version && !dependencies.has(name) && !isWorkspacePackage) { dependencies.set(name, { name, version: info.version, path: info.path, }); } } } logger?.debug(` Found ${dependencies.size} dependencies.`); return dependencies; } /** * 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'` with a `data` property. * Yarn classic does not provide a path, so the `path` property will be `undefined`. * * ```json * {"type":"tree","data":{"trees":[{"name":"@angular/cli@18.0.0","children":[]}]}} * ``` * * @param stdout The standard output of the command. * @param logger An optional logger instance. * @returns A map of package names to their installed package details. */ export function parseYarnClassicDependencies( stdout: string, logger?: Logger, ): Map<string, InstalledPackage> { logger?.debug(`Parsing yarn classic dependency list...`); logStdout(stdout, logger); const dependencies = new Map<string, InstalledPackage>(); if (!stdout) { logger?.debug(' stdout is empty. No dependencies found.'); return dependencies; } for (const json of parseJsonLines(stdout, logger)) { if (json.type === 'tree' && json.data?.trees) { for (const info of json.data.trees) { const lastAtIndex = info.name.lastIndexOf('@'); const name = info.name.slice(0, lastAtIndex); const version = info.name.slice(lastAtIndex + 1); dependencies.set(name, { name, version, }); } } } logger?.debug(` Found ${dependencies.size} dependencies.`); return dependencies; } function isValidManifest(obj: unknown): obj is PackageManifest { if (typeof obj !== 'object' || obj === null) { return false; } const { name, version } = obj as Record<string, unknown>; return typeof name === 'string' && typeof version === 'string' && valid(version) !== null; } function isValidMetadata(obj: unknown): obj is PackageMetadata { if (typeof obj !== 'object' || obj === null) { return false; } const { name, versions, 'dist-tags': distTags } = obj as Record<string, unknown>; return ( typeof name === 'string' && Array.isArray(versions) && typeof distTags === 'object' && distTags !== null ); } /** * Parses the output of `npm view` or a compatible command to get a package manifest. * @param stdout The standard output of the command. * @param logger An optional logger instance. * @returns The package manifest object. */ export function parseNpmLikeManifest(stdout: string, logger?: Logger): PackageManifest | null { logger?.debug(`Parsing npm-like manifest...`); logStdout(stdout, logger); if (!stdout) { logger?.debug(' stdout is empty. No manifest found.'); return null; } const result = JSON.parse(stdout); // npm view returns an array of manifests if the query matches multiple versions // (e.g. when using a version range). We find the highest version to ensure // we get the latest relevant manifest, even if the output is not sorted. if (Array.isArray(result)) { let maxManifest: PackageManifest | null = null; for (const manifest of result) { if (!isValidManifest(manifest)) { logger?.debug( ' Skipping invalid manifest in array (missing name, version, or invalid SemVer).', ); continue; } if (!maxManifest || compare(manifest.version, maxManifest.version) > 0) { maxManifest = manifest; } } if (!maxManifest) { logger?.debug(' No valid manifests found in the array.'); } return maxManifest; } if (!isValidManifest(result)) { logger?.debug( ' Parsed JSON is not a valid manifest (missing name, version, or invalid SemVer).', ); return null; } return result; } /** * Parses the output of `npm view` or a compatible command to get package metadata. * @param stdout The standard output of the command. * @param logger An optional logger instance. * @returns The package metadata object. */ export function parseNpmLikeMetadata(stdout: string, logger?: Logger): PackageMetadata | null { logger?.debug(`Parsing npm-like metadata...`); logStdout(stdout, logger); if (!stdout) { logger?.debug(' stdout is empty. No metadata found.'); return null; } const result = JSON.parse(stdout); // npm 12+ `npm view --json` always returns an array of objects. if (Array.isArray(result)) { for (const item of result) { if (isValidMetadata(item)) { return item; } } logger?.debug(' No valid metadata found in the array.'); return null; } if (!isValidMetadata(result)) { logger?.debug(' Parsed JSON is not valid metadata (missing name, versions, or dist-tags).'); return null; } return result; } /** * 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, or `null` if not found. */ export function parseYarnClassicManifest(stdout: string, logger?: Logger): PackageManifest | null { logger?.debug(`Parsing yarn classic manifest...`); logStdout(stdout, logger); if (!stdout) { logger?.debug(' stdout is empty. No manifest found.'); return null; } // 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; } } 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 // Add a default of 'false' for the `save` field when the `ng-add` object is present but does not have any fields. // There is a small chance this causes an incorrect value. However, the use of `ng-add` is rare and, in the cases // it is used, save is set to either a `false` literal or a truthy value. Special cases can be added for specific // packages if discovered. if ( manifest['ng-add'] && typeof manifest['ng-add'] === 'object' && Object.keys(manifest['ng-add']).length === 0 ) { manifest['ng-add'].save ??= false; } if (!isValidManifest(manifest)) { logger?.debug( ' Parsed JSON is not a valid manifest (missing name, version, or invalid SemVer).', ); return null; } return manifest; } /** * Parses the output of `yarn info` (classic) to get package metadata. * @param stdout The standard output of the command. * @param logger An optional logger instance. * @returns The package metadata object. */ export function parseYarnClassicMetadata(stdout: string, logger?: Logger): PackageMetadata | null { logger?.debug(`Parsing yarn classic metadata...`); logStdout(stdout, logger); if (!stdout) { logger?.debug(' stdout is empty. No metadata found.'); return null; } // 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, npm 12) try { let jsonError = JSON.parse(output); if (Array.isArray(jsonError)) { jsonError = jsonError[0]; } if (jsonError && typeof jsonError === 'object' && 'error' in jsonError) { jsonError = jsonError.error; } 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.'); return null; } /** * Parses the output of `bun pm ls`. * * Bun does not support JSON output for `pm ls`. The output is a tree structure: * ``` * /path/to/project node_modules (1084) * ├── @angular/core@20.3.15 * ├── rxjs @7.8.2 * └── zone.js @0.15.1 * ``` * * @param stdout The standard output of the command. * @param logger An optional logger instance. * @returns A map of package names to their installed package details. */ export function parseBunDependencies( stdout: string, logger?: Logger, ): Map<string, InstalledPackage> { logger?.debug('Parsing Bun dependency list...'); logStdout(stdout, logger); const dependencies = new Map<string, InstalledPackage>(); if (!stdout) { return dependencies; } const lines = stdout.split('\n'); // Skip the first line (project info) for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) { continue; } // Remove tree structure characters const cleanLine = line.replace(/^[└├]──\s*/, ''); // Parse name and version // Scoped: @angular/core@20.3.15 // Unscoped: rxjs @7.8.2 const match = cleanLine.match(/^(.+?)\s?@([^@\s]+)$/); if (match) { const name = match[1]; const version = match[2]; dependencies.set(name, { name, version }); } } logger?.debug(` Found ${dependencies.size} dependencies.`); return dependencies; } /** * Parses the output of `yarn info --name-only --json`. * * The expected output is a JSON stream (JSONL) of strings. * Each string represents a package locator. * * ``` * "karma@npm:6.4.4" * "@angular/core@npm:20.3.15" * ``` * * @param stdout The standard output of the command. * @param logger An optional logger instance. * @returns A map of package names to their installed package details. */ export function parseYarnModernDependencies( stdout: string, logger?: Logger, ): Map<string, InstalledPackage> { logger?.debug('Parsing Yarn Berry dependency list...'); logStdout(stdout, logger); const dependencies = new Map<string, InstalledPackage>(); if (!stdout) { return dependencies; } for (const json of parseJsonLines(stdout, logger)) { if (typeof json === 'string') { const match = json.match(/^(@?[^@]+)@(.+)$/); if (match) { const name = match[1]; let version = match[2]; // Handle "npm:" prefix if (version.startsWith('npm:')) { version = version.slice(4); } // Handle complex locators with embedded version metadata (e.g., "patch:...", "virtual:...") // Yarn Berry often appends metadata like "::version=x.y.z" const versionParamMatch = version.match(/::version=([^&]+)/); if (versionParamMatch) { version = versionParamMatch[1]; } dependencies.set(name, { name, version }); } } } logger?.debug(` Found ${dependencies.size} dependencies.`); return dependencies; } /** * Parses the output of the pnpm minimum-release-age config command. * @param output The string output to parse (in minutes). * @param version The active package manager version string. * @returns The duration in milliseconds. */ export function parsePnpmReleaseAge(output: string, version: string): number { const value = output.trim(); // If the setting is empty, or undefined/null string placeholder, no age limit is active. if (!value || value === 'undefined' || value === 'null') { // Starting with pnpm version 11, the default value for minimum-release-age is 24 hours (1440 minutes). // In prior versions, it defaulted to 0. const major = parseInt(version.split('.')[0], 10); if (major >= 11) { return 1440 * 60000; } return 0; } // PNPM config outputs duration values without a unit as minutes (e.g. 1440). const minutes = parseInt(value, 10); return isNaN(minutes) ? 0 : minutes * 60000; } /** * Parses the output of the yarn npmMinimalAgeGate config command. * @param output The string output to parse (duration string or minutes). * @returns The duration in milliseconds. */ export function parseYarnReleaseAge(output: string, version: string): number { const value = output.trim(); // If the setting is empty, or undefined/null string placeholder, no age limit is active. if (!value || value === 'undefined' || value === 'null') { // Starting with yarn version 4, the default value for minimum-release-age is 24 hours (1440 minutes). // In prior versions, it defaulted to 0. const major = parseInt(version.split('.')[0], 10); if (major >= 4) { return 1440 * 60000; } return 0; } // Parse Yarn duration format (e.g. "3d", "24h") or a raw minute value. const match = value.match(/^(\d+)(ms|s|m|h|d|w)?$/); if (!match) { return 0; } const amount = parseInt(match[1], 10); const unit = match[2]; if (unit) { switch (unit) { case 'ms': return amount; case 's': return amount * 1000; case 'm': return amount * 60000; case 'h': return amount * 3600000; case 'd': return amount * 86400000; case 'w': return amount * 604800000; } } // Yarn unit-less config value is always minutes. return amount * 60000; } /** * Parses the output of the npm before config option. * Converts the absolute cutoff date into a relative age in milliseconds. * @param output The date string to parse. * @returns The age in milliseconds. */ export function parseNpmBeforeDate(output: string): number { const trimmed = output.trim(); if (!trimmed || trimmed === 'null' || trimmed === 'undefined') { return 0; } const parsedDate = Date.parse(trimmed); if (isNaN(parsedDate)) { return 0; } const age = Date.now() - parsedDate; return age > 0 ? age : 0; }