Skip to content

Commit bfe55df

Browse files
committed
fix(@angular/cli): strip terminal escape sequences in modern yarn error parser
Modern Yarn formats error detail fields such as `Response Code` with ANSI color sequences and OSC 8 hyperlinks before passing them to `StreamReport`, which serializes the formatted strings verbatim into its JSON line output. When colors are enabled, such as by default on GitHub Actions even over non-TTY pipes or via `FORCE_COLOR` and `.yarnrc.yml` settings, escape codes appear around both the `Response Code` label and status number. As a result, `parseYarnModernError` failed to match the HTTP status code, fell back to the generic `YN0035` code instead of `E404` for missing packages, and leaked raw control sequences into the parsed summary and detail text. Strip VT control characters from modern Yarn error messages prior to matching an anchored `Response Code` line, and ignore 2xx status codes for consistency with the Yarn Classic parser. In addition, split multi-line error payloads such as `YN0001` exception stack traces so `summary` only retains the first line while remaining lines move into `detail`, fall back to `UNKNOWN_ERROR` when `displayName` is an empty string (when `enableMessageNames` is disabled), and return `null` directly when no JSON error lines are present instead of delegating to `parseNpmLikeError` on every successful command output.
1 parent ef600f2 commit bfe55df

3 files changed

Lines changed: 157 additions & 26 deletions

File tree

‎packages/angular/cli/src/package-managers/package-manager_spec.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,38 @@ describe('PackageManager', () => {
439439

440440
expect(await pm.getRegistryMetadata('non-existent')).toBeNull();
441441
});
442+
443+
it('should throw a PackageManagerError with the parsed summary on non-404 modern yarn errors', async () => {
444+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
445+
runCommandSpy.and.rejectWith(
446+
new PackageManagerError(
447+
'Process exited with code 1.',
448+
[
449+
JSON.stringify({
450+
type: 'error',
451+
name: 35,
452+
displayName: 'YN0035',
453+
indent: '',
454+
data: 'The remote server failed to provide the requested resource',
455+
}),
456+
JSON.stringify({
457+
type: 'error',
458+
name: 35,
459+
displayName: 'YN0035',
460+
indent: '',
461+
data: ' Response Code: 403 (Forbidden)',
462+
}),
463+
].join('\n'),
464+
'',
465+
1,
466+
),
467+
);
468+
469+
await expectAsync(pm.getRegistryMetadata('@private/pkg')).toBeRejectedWithError(
470+
PackageManagerError,
471+
'The remote server failed to provide the requested resource',
472+
);
473+
});
442474
});
443475

444476
describe('getCurrentPackageName', () => {

‎packages/angular/cli/src/package-managers/parsers.ts‎

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* into their own file improves modularity and allows for focused testing.
1313
*/
1414

15+
import { stripVTControlCharacters } from 'node:util';
1516
import { compare, valid } from 'semver';
1617
import { ErrorInfo } from './error';
1718
import { Logger } from './logger';
@@ -477,10 +478,11 @@ export function parseYarnClassicMetadata(stdout: string, logger?: Logger): Packa
477478
}
478479

479480
/**
480-
* Parses the `stdout` or `stderr` output of npm, pnpm, modern yarn, or bun to extract structured error information.
481+
* Parses the `stdout` or `stderr` output of npm or pnpm to extract structured error information.
482+
* It is also used by `parseBunError` as a fallback for npm-compatible JSON error objects.
481483
*
482484
* This parser uses a multi-stage approach. It first attempts to parse the entire `output` as a
483-
* single JSON object, which is the standard for modern tools like pnpm, yarn, and bun. If JSON
485+
* single JSON object, which is the standard for modern tools like pnpm and npm. If JSON
484486
* parsing fails, it falls back to a line-by-line regex-based approach to handle the plain
485487
* text output from older versions of npm.
486488
*
@@ -513,7 +515,7 @@ export function parseNpmLikeError(output: string, logger?: Logger): ErrorInfo |
513515
return null;
514516
}
515517

516-
// Attempt to parse as JSON first (common for pnpm, modern yarn, npm 12)
518+
// Attempt to parse as JSON first (common for pnpm, npm 12, and the Bun JSON fallback)
517519
try {
518520
let jsonError = JSON.parse(output);
519521
if (Array.isArray(jsonError)) {
@@ -639,9 +641,9 @@ export function parseYarnClassicError(output: string, logger?: Logger): ErrorInf
639641
/**
640642
* Parses the `stdout` or `stderr` output of modern yarn (v2+) to extract structured error information.
641643
*
642-
* Modern yarn reports errors as JSON lines, one per message line. The first line holds the summary,
643-
* and registry errors include the HTTP status, which is returned as a standardized error code
644-
* (`E${statusCode}`). Other output is delegated to `parseNpmLikeError`.
644+
* Modern yarn reports errors as JSON lines, one per message line, which may include ANSI color codes
645+
* or OSC 8 hyperlinks when colors are enabled (e.g., in CI). The first line holds the summary, and
646+
* registry errors include the HTTP status, which is returned as a standardized error code (`E${statusCode}`).
645647
*
646648
* Example output (`yarn npm info <missing-package> --json`):
647649
* ```json
@@ -666,29 +668,55 @@ export function parseYarnModernError(output: string, logger?: Logger): ErrorInfo
666668
const messages: { code: string; data: string }[] = [];
667669
for (const json of parseJsonLines(output, logger)) {
668670
if (json?.type === 'error' && typeof json.data === 'string') {
669-
messages.push({ code: String(json.displayName ?? 'UNKNOWN_ERROR'), data: json.data });
671+
const displayName =
672+
typeof json.displayName === 'string'
673+
? stripVTControlCharacters(json.displayName).trim()
674+
: '';
675+
messages.push({
676+
code: displayName || 'UNKNOWN_ERROR',
677+
data: stripVTControlCharacters(json.data),
678+
});
670679
}
671680
}
672681

673682
if (messages.length === 0) {
674-
return parseNpmLikeError(output, logger);
683+
logger?.debug(' Failed to parse modern yarn error. No structured error found.');
684+
685+
return null;
686+
}
687+
688+
let statusCode: string | undefined;
689+
for (const { data } of messages) {
690+
const match = data.match(/^\s*Response Code:\s*(\d{3})\b/);
691+
if (match) {
692+
const numericCode = Number(match[1]);
693+
if (numericCode < 200 || numericCode >= 300) {
694+
statusCode = match[1];
695+
break;
696+
}
697+
}
675698
}
676699

677700
const [first, ...rest] = messages;
678-
const statusCodeMatch = messages
679-
.map(({ data }) => data.match(/Response Code: (\d{3})/))
680-
.find((match) => match !== null);
681-
const code = statusCodeMatch ? `E${statusCodeMatch[1]}` : first.code;
701+
const code = statusCode ? `E${statusCode}` : first.code;
682702
logger?.debug(` Successfully parsed modern yarn error with code '${code}'.`);
683703

704+
const [summary = '', ...firstDetailLines] = first.data
705+
.split('\n')
706+
.map((line) => line.trim())
707+
.filter(Boolean);
708+
const detailLines = [
709+
...firstDetailLines,
710+
...rest
711+
.flatMap(({ data }) => data.split('\n'))
712+
.map((line) => line.trim())
713+
.filter(Boolean),
714+
];
715+
684716
return {
685717
code,
686-
summary: first.data.trim(),
687-
detail:
688-
rest
689-
.map(({ data }) => data.trim())
690-
.filter(Boolean)
691-
.join('\n') || undefined,
718+
summary,
719+
detail: detailLines.join('\n') || undefined,
692720
};
693721
}
694722

‎packages/angular/cli/src/package-managers/parsers_spec.ts‎

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -643,28 +643,99 @@ project node_modules
643643
});
644644

645645
describe('parseYarnModernError', () => {
646-
it('should parse a missing package from yarn npm info as E404', () => {
646+
it('should parse a missing package from yarn npm info as E404 with detail', () => {
647647
const stdout = [
648648
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Package not found"}',
649649
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Response Code: 404 (Not Found)"}',
650650
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Request Method: GET"}',
651651
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" Request URL: https://registry.yarnpkg.com/@angular%2fnon-existent"}',
652652
].join('\n');
653-
expect(parseYarnModernError(stdout)).toEqual(
654-
jasmine.objectContaining({ code: 'E404', summary: 'Package not found' }),
655-
);
653+
expect(parseYarnModernError(stdout)).toEqual({
654+
code: 'E404',
655+
summary: 'Package not found',
656+
detail: [
657+
'Response Code: 404 (Not Found)',
658+
'Request Method: GET',
659+
'Request URL: https://registry.yarnpkg.com/@angular%2fnon-existent',
660+
].join('\n'),
661+
});
662+
});
663+
664+
it('should strip ANSI color codes and OSC 8 hyperlinks from error lines', () => {
665+
const stdout = [
666+
JSON.stringify({
667+
type: 'error',
668+
name: 35,
669+
displayName: 'YN0035',
670+
indent: '',
671+
data: '\u001b[31mPackage not found\u001b[39m',
672+
}),
673+
JSON.stringify({
674+
type: 'error',
675+
name: 35,
676+
displayName: 'YN0035',
677+
indent: '',
678+
data: ' \u001b[38;5;111mResponse Code\u001b[39m: \u001b]8;;https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404\u0007\u001b[38;5;220m404\u001b[39m (Not Found)\u001b]8;;\u0007',
679+
}),
680+
].join('\n');
681+
expect(parseYarnModernError(stdout)).toEqual({
682+
code: 'E404',
683+
summary: 'Package not found',
684+
detail: 'Response Code: 404 (Not Found)',
685+
});
656686
});
657687

658688
it('should use the yarn error code when there is no HTTP status', () => {
659689
const stdout =
660690
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Request timed out"}';
661-
expect(parseYarnModernError(stdout)).toEqual(
662-
jasmine.objectContaining({ code: 'YN0035', summary: 'Request timed out' }),
663-
);
691+
expect(parseYarnModernError(stdout)).toEqual({
692+
code: 'YN0035',
693+
summary: 'Request timed out',
694+
detail: undefined,
695+
});
696+
});
697+
698+
it('should omit detail when trailing error lines are empty or whitespace-only', () => {
699+
const stdout = [
700+
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":"Request timed out"}',
701+
'{"type":"error","name":35,"displayName":"YN0035","indent":"","data":" "}',
702+
].join('\n');
703+
expect(parseYarnModernError(stdout)).toEqual({
704+
code: 'YN0035',
705+
summary: 'Request timed out',
706+
detail: undefined,
707+
});
708+
});
709+
710+
it('should split multi-line exception stack traces into summary and detail', () => {
711+
const stdout = JSON.stringify({
712+
type: 'error',
713+
name: 1,
714+
displayName: 'YN0001',
715+
indent: '',
716+
data: 'RequestError: getaddrinfo ENOTFOUND registry.yarnpkg.com\n at GetAddrInfoReqWrap.onlookupall (node:dns:120:26)',
717+
});
718+
expect(parseYarnModernError(stdout)).toEqual({
719+
code: 'YN0001',
720+
summary: 'RequestError: getaddrinfo ENOTFOUND registry.yarnpkg.com',
721+
detail: 'at GetAddrInfoReqWrap.onlookupall (node:dns:120:26)',
722+
});
723+
});
724+
725+
it('should fall back to UNKNOWN_ERROR when displayName is empty or missing', () => {
726+
const stdout =
727+
'{"type":"error","name":35,"displayName":"","indent":"","data":"Network disabled"}';
728+
expect(parseYarnModernError(stdout)).toEqual({
729+
code: 'UNKNOWN_ERROR',
730+
summary: 'Network disabled',
731+
detail: undefined,
732+
});
664733
});
665734

666-
it('should return null for successful or empty output', () => {
735+
it('should return null for successful, empty, or unrecognized output', () => {
667736
expect(parseYarnModernError('{"name":"@angular/core","version":"22.2.4"}')).toBeNull();
737+
expect(parseYarnModernError('"@angular/core@npm:22.2.4"\n"rxjs@npm:7.8.2"')).toBeNull();
738+
expect(parseYarnModernError('some unrelated plain-text output')).toBeNull();
668739
expect(parseYarnModernError('')).toBeNull();
669740
});
670741
});

0 commit comments

Comments
 (0)