Skip to content

Commit ee61e32

Browse files
clydinalan-agius4
authored andcommitted
fix(@angular/cli): handle bun registry error output and 404 detection
Unlike npm, `bun pm view` does not emit `npm ERR! code E404` or nested JSON error objects with a `code` property when a package, version, or dist-tag is missing from the registry. Instead, missing packages produce plain-text HTTP status lines on `stderr` (such as `404 Not Found: ...`), while missing versions or dist-tags produce flat JSON objects on `stdout` with a string `error` field (such as `{ "error": "No matching version found" }`). Because `parseNpmLikeError` did not recognize either format, querying a non-existent package or version under Bun threw an unhandled `PackageManagerError` instead of returning `null`. This change introduces a dedicated `parseBunError` parser for the `bun` package manager descriptor that extracts structured error codes from both Bun's JSON error payloads and plain-text HTTP status messages, normalizing not-found responses to `E404`. (cherry picked from commit 322777d)
1 parent 0d8898c commit ee61e32

4 files changed

Lines changed: 222 additions & 2 deletions

File tree

‎packages/angular/cli/src/package-managers/package-manager-descriptor.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { PackageManifest, PackageMetadata } from './package-metadata';
1818
import { InstalledPackage } from './package-tree';
1919
import {
2020
parseBunDependencies,
21+
parseBunError,
2122
parseNpmBeforeDate,
2223
parseNpmLikeDependencies,
2324
parseNpmLikeError,
@@ -349,7 +350,7 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
349350
listDependencies: parseBunDependencies,
350351
getRegistryManifest: parseNpmLikeManifest,
351352
getRegistryMetadata: parseNpmLikeMetadata,
352-
getError: parseNpmLikeError,
353+
getError: parseBunError,
353354
},
354355
isNotFound: isKnownNotFound,
355356
},

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { PackageManagerError } from './error';
910
import { MANIFEST_FIELDS, PackageManager } from './package-manager';
1011
import { SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor';
1112
import { MockHost } from './testing/mock-host';
@@ -54,6 +55,37 @@ describe('PackageManager', () => {
5455
jasmine.anything(),
5556
);
5657
});
58+
59+
it('should return null when bun pm view fails with No matching version found', async () => {
60+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']);
61+
runCommandSpy.and.rejectWith(
62+
new PackageManagerError(
63+
'Process exited with code 1.',
64+
JSON.stringify({ error: 'No matching version found', version: 'foo@99.99.99' }),
65+
'',
66+
1,
67+
),
68+
);
69+
70+
expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull();
71+
});
72+
73+
it('should throw when bun pm view fails with an unrecognized JSON error', async () => {
74+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']);
75+
runCommandSpy.and.rejectWith(
76+
new PackageManagerError(
77+
'Process exited with code 1.',
78+
JSON.stringify({ error: 'Authentication token not found' }),
79+
'',
80+
1,
81+
),
82+
);
83+
84+
await expectAsync(pm.getRegistryManifest('foo', '1.0.0')).toBeRejectedWithError(
85+
PackageManagerError,
86+
'Authentication token not found',
87+
);
88+
});
5789
});
5890

5991
describe('getVersion', () => {
@@ -376,6 +408,20 @@ describe('PackageManager', () => {
376408
jasmine.anything(),
377409
);
378410
});
411+
412+
it('should return null when bun pm view fails with 404 Not Found', async () => {
413+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']);
414+
runCommandSpy.and.rejectWith(
415+
new PackageManagerError(
416+
'Process exited with code 1.',
417+
'',
418+
"404 Not Found: https://registry.npmjs.org/non-existent\n\n - 'non-existent@latest' does not exist in this registry",
419+
1,
420+
),
421+
);
422+
423+
expect(await pm.getRegistryMetadata('non-existent')).toBeNull();
424+
});
379425
});
380426

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

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

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ export function parseNpmLikeError(output: string, logger?: Logger): ErrorInfo |
513513
return null;
514514
}
515515

516-
// Attempt to parse as JSON first (common for pnpm, modern yarn, bun, npm 12)
516+
// Attempt to parse as JSON first (common for pnpm, modern yarn, npm 12)
517517
try {
518518
let jsonError = JSON.parse(output);
519519
if (Array.isArray(jsonError)) {
@@ -636,6 +636,92 @@ export function parseYarnClassicError(output: string, logger?: Logger): ErrorInf
636636
return null;
637637
}
638638

639+
/**
640+
* Known Bun JSON error messages that indicate a requested version or dist-tag does not exist.
641+
*/
642+
const BUN_NOT_FOUND_ERROR_PATTERN = /^(?:no matching version found|dist-tag not found)\b/i;
643+
644+
/**
645+
* Parses the `stdout` or `stderr` output of `bun` commands to extract structured error information.
646+
*
647+
* Handles flat JSON error payloads emitted on `stdout` by `bun pm view` (e.g., when a requested
648+
* version or dist-tag does not exist) as well as plain-text HTTP status errors emitted on `stderr`
649+
* (e.g., 404 Not Found). Other JSON error shapes are delegated to `parseNpmLikeError`.
650+
*
651+
* Example JSON error output:
652+
* ```json
653+
* { "error": "No matching version found", "version": "@angular/core@99.99.99" }
654+
* ```
655+
*
656+
* Example plain-text error output:
657+
* ```
658+
* 404 Not Found: https://registry.npmjs.org/@angular%2fnon-existent
659+
* ```
660+
*
661+
* @param output The standard output or standard error of the command.
662+
* @param logger An optional logger instance.
663+
* @returns An `ErrorInfo` object if parsing is successful, otherwise `null`.
664+
*/
665+
export function parseBunError(output: string, logger?: Logger): ErrorInfo | null {
666+
logger?.debug('Parsing Bun error output...');
667+
logStdout(output, logger);
668+
669+
if (!output) {
670+
logger?.debug(' output is empty. No error found.');
671+
672+
return null;
673+
}
674+
675+
// 1. JSON output. Valid JSON is never a plain-text status line, so text matching is skipped.
676+
let parsed: unknown;
677+
let isJson = false;
678+
try {
679+
parsed = JSON.parse(output);
680+
isJson = true;
681+
} catch {
682+
// Not JSON. Fall back to plain-text error matching.
683+
}
684+
685+
if (isJson) {
686+
// Only accept Bun's exact flat error shape (`error` with an optional `version`) to avoid
687+
// misinterpreting successful output, such as a dist-tags object containing an `error` tag.
688+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
689+
const { error, version, ...rest } = parsed as Record<string, unknown>;
690+
if (
691+
typeof error === 'string' &&
692+
(version === undefined || typeof version === 'string') &&
693+
Object.keys(rest).length === 0
694+
) {
695+
const code = BUN_NOT_FOUND_ERROR_PATTERN.test(error) ? 'E404' : 'UNKNOWN_ERROR';
696+
const summary = version ? `${error}: ${version}` : error;
697+
logger?.debug(` Successfully parsed Bun JSON error with code '${code}'.`);
698+
699+
return { code, summary };
700+
}
701+
}
702+
703+
// Fall back to npm-compatible JSON error objects (e.g., `{ "error": { "code": "E404", ... } }`).
704+
return parseNpmLikeError(output, logger);
705+
}
706+
707+
// 2. Plain-text HTTP error status lines from `bun pm view`
708+
// (e.g., `404 Not Found: https://registry.npmjs.org/...`).
709+
const httpStatusMatch = output.match(/^([45]\d{2})\s+[^\r\n:]+:.+$/m);
710+
if (httpStatusMatch) {
711+
const code = `E${httpStatusMatch[1]}`;
712+
logger?.debug(` Detected HTTP error status code '${httpStatusMatch[1]}' in Bun output.`);
713+
714+
return {
715+
code,
716+
summary: httpStatusMatch[0].trim(),
717+
};
718+
}
719+
720+
logger?.debug(' Failed to parse Bun error. No structured error found.');
721+
722+
return null;
723+
}
724+
639725
/**
640726
* Parses the output of `bun pm ls`.
641727
*

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {
1010
parseBunDependencies,
11+
parseBunError,
1112
parseNpmBeforeDate,
1213
parseNpmLikeDependencies,
1314
parseNpmLikeError,
@@ -554,6 +555,92 @@ project node_modules
554555
});
555556
});
556557

558+
describe('parseBunError', () => {
559+
it('should parse JSON missing version error as E404', () => {
560+
const stdout = JSON.stringify({
561+
error: 'No matching version found',
562+
version: '@angular/core@99.99.99',
563+
});
564+
expect(parseBunError(stdout)).toEqual({
565+
code: 'E404',
566+
summary: 'No matching version found: @angular/core@99.99.99',
567+
});
568+
});
569+
570+
it('should parse JSON missing dist-tag error as E404', () => {
571+
const stdout = JSON.stringify({
572+
error: 'Dist-tag not found',
573+
version: '@angular/core@nonexistent-tag',
574+
});
575+
expect(parseBunError(stdout)).toEqual({
576+
code: 'E404',
577+
summary: 'Dist-tag not found: @angular/core@nonexistent-tag',
578+
});
579+
});
580+
581+
it('should parse plain-text 404 Not Found output from bun pm view as E404', () => {
582+
const stderr = `
583+
404 Not Found: https://registry.npmjs.org/@angular%2fnon-existent
584+
585+
- '@angular/non-existent@latest' does not exist in this registry
586+
`.trim();
587+
expect(parseBunError(stderr)).toEqual({
588+
code: 'E404',
589+
summary: '404 Not Found: https://registry.npmjs.org/@angular%2fnon-existent',
590+
});
591+
});
592+
593+
it('should parse plain-text 401 Unauthorized output from bun pm view as E401', () => {
594+
const stderr = '401 Unauthorized: https://registry.npmjs.org/@private%2fpkg';
595+
expect(parseBunError(stderr)).toEqual({
596+
code: 'E401',
597+
summary: '401 Unauthorized: https://registry.npmjs.org/@private%2fpkg',
598+
});
599+
});
600+
601+
it('should parse unrecognized JSON error messages as UNKNOWN_ERROR', () => {
602+
const stdout = JSON.stringify({ error: 'Authentication token not found' });
603+
expect(parseBunError(stdout)).toEqual({
604+
code: 'UNKNOWN_ERROR',
605+
summary: 'Authentication token not found',
606+
});
607+
});
608+
609+
it('should fall back to npm-compatible JSON error objects', () => {
610+
const stdout = JSON.stringify({ error: { code: 'E403', summary: 'Forbidden' } });
611+
expect(parseBunError(stdout)).toEqual({
612+
code: 'E403',
613+
summary: 'Forbidden',
614+
detail: undefined,
615+
});
616+
});
617+
618+
it('should not treat successful bun pm view JSON output as an error', () => {
619+
// A dist-tags object that happens to contain an `error` tag.
620+
expect(parseBunError(JSON.stringify({ latest: '1.0.0', error: '0.0.1' }))).toBeNull();
621+
expect(parseBunError(JSON.stringify(['1.0.0', '1.1.0']))).toBeNull();
622+
expect(parseBunError(JSON.stringify({ name: 'foo', version: '1.0.0' }))).toBeNull();
623+
});
624+
625+
it('should not treat successful bun pm ls output as an error', () => {
626+
const stdout = `
627+
/workspace node_modules (350)
628+
├── @angular/core@21.2.4
629+
└── typescript@5.9.3
630+
`.trim();
631+
expect(parseBunError(stdout)).toBeNull();
632+
});
633+
634+
it('should ignore non-error HTTP status lines', () => {
635+
expect(parseBunError('304 Not Modified: https://registry.npmjs.org/foo')).toBeNull();
636+
});
637+
638+
it('should return null for empty or unrecognized output', () => {
639+
expect(parseBunError('')).toBeNull();
640+
expect(parseBunError('some unrelated output')).toBeNull();
641+
});
642+
});
643+
557644
describe('parseYarnModernDependencies', () => {
558645
it('should parse yarn info --name-only --json output', () => {
559646
const stdout = `

0 commit comments

Comments
 (0)