From cf6c9076699cba8d2f227e9b162eb773d0c19cd1 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:14:23 -0400 Subject: [PATCH 1/5] refactor(@angular/cli): migrate `ng add` to new package manager abstraction This commit fully refactors the `ng add` command to use the new, centralized package manager abstraction. All package installation and metadata fetching logic now goes through the new API. This includes: - Using `createPackageManager` to detect the project's package manager. - Replacing `fetchPackageMetadata` with `packageManager.getRegistryMetadata`. - Replacing `fetchPackageManifest` with `packageManager.getManifest`. - Replacing the `install` and `installTemp` methods with the `packageManager.add` and `packageManager.acquireTempPackage` methods. This change improves security by eliminating `shell: true` execution as well as enhances reliability with better error handling and caching. --- packages/angular/cli/src/commands/add/cli.ts | 227 ++++++++++--------- 1 file changed, 117 insertions(+), 110 deletions(-) diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index aec0014b9114..f20116dd1222 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -8,12 +8,12 @@ import { Listr, ListrRenderer, ListrTaskWrapper, color, figures } from 'listr2'; import assert from 'node:assert'; +import { promises as fs } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import npa from 'npm-package-arg'; import { Range, compare, intersects, prerelease, satisfies, valid } from 'semver'; import { Argv } from 'yargs'; -import { PackageManager } from '../../../lib/config/workspace-schema'; import { CommandModuleImplementation, Options, @@ -23,14 +23,14 @@ import { SchematicsCommandArgs, SchematicsCommandModule, } from '../../command-builder/schematics-command-module'; -import { assertIsError } from '../../utilities/error'; import { NgAddSaveDependency, + PackageManager, PackageManifest, PackageMetadata, - fetchPackageManifest, - fetchPackageMetadata, -} from '../../utilities/package-metadata'; + createPackageManager, +} from '../../package-managers'; +import { assertIsError } from '../../utilities/error'; import { isTTY } from '../../utilities/tty'; import { VERSION } from '../../utilities/version'; @@ -44,8 +44,8 @@ interface AddCommandArgs extends SchematicsCommandArgs { } interface AddCommandTaskContext { + packageManager: PackageManager; packageIdentifier: npa.Result; - usingYarn?: boolean; savePackage?: NgAddSaveDependency; collectionName?: string; executeSchematic: AddCommandModule['executeSchematic']; @@ -173,12 +173,12 @@ export default class AddCommandModule } } - const taskContext: AddCommandTaskContext = { + const taskContext = { packageIdentifier, executeSchematic: this.executeSchematic.bind(this), getPeerDependencyConflicts: this.getPeerDependencyConflicts.bind(this), dryRun: options.dryRun, - }; + } as AddCommandTaskContext; const tasks = new Listr( [ @@ -194,7 +194,7 @@ export default class AddCommandModule rendererOptions: { persistentOutput: true }, }, { - title: 'Loading package information from registry', + title: 'Loading package information', task: (context, task) => this.loadPackageInfoTask(context, task, options), rendererOptions: { persistentOutput: true }, }, @@ -294,13 +294,16 @@ export default class AddCommandModule } } - private determinePackageManagerTask( + private async determinePackageManagerTask( context: AddCommandTaskContext, task: AddCommandTaskWrapper, - ): void { - const { packageManager } = this.context; - context.usingYarn = packageManager.name === PackageManager.Yarn; - task.output = `Using package manager: ${color.dim(packageManager.name)}`; + ): Promise { + context.packageManager = await createPackageManager({ + cwd: this.context.root, + logger: this.context.logger, + dryRun: context.dryRun, + }); + task.output = `Using package manager: ${color.dim(context.packageManager.name)}`; } private async findCompatiblePackageVersionTask( @@ -308,77 +311,87 @@ export default class AddCommandModule task: AddCommandTaskWrapper, options: Options, ): Promise { - const { logger } = this.context; - const { verbose, registry } = options; + const { registry, verbose } = options; + const { packageManager, packageIdentifier } = context; + const packageName = packageIdentifier.name; - assert( - context.packageIdentifier.name, - 'Registry package identifiers should always have a name.', - ); + assert(packageName, 'Registry package identifiers should always have a name.'); - // only package name provided; search for viable version - // plus special cases for packages that did not have peer deps setup - let packageMetadata; + const rejectionReasons: string[] = []; + + // Attempt to use the 'latest' tag from the registry. try { - packageMetadata = await fetchPackageMetadata(context.packageIdentifier.name, logger, { + const latestManifest = await packageManager.getManifest(`${packageName}@latest`, { registry, - usingYarn: context.usingYarn, - verbose, }); + + if (latestManifest) { + const conflicts = await this.getPeerDependencyConflicts(latestManifest); + if (!conflicts) { + context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); + task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`; + + return; + } + rejectionReasons.push(...conflicts); + } } catch (e) { assertIsError(e); throw new CommandError(`Unable to load package information from registry: ${e.message}`); } - const rejectionReasons: string[] = []; + // 'latest' is invalid or not found, search for most recent matching package. + task.output = + 'Could not find a compatible version with `latest`. Searching for a compatible version.'; - // Start with the version tagged as `latest` if it exists - const latestManifest = packageMetadata.tags['latest']; - if (latestManifest) { - const latestConflicts = await this.getPeerDependencyConflicts(latestManifest); - if (latestConflicts) { - // 'latest' is invalid so search for most recent matching package - rejectionReasons.push(...latestConflicts); - } else { - context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); - task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`; + let packageMetadata; + try { + packageMetadata = await packageManager.getRegistryMetadata(packageName, { + registry, + }); + } catch (e) { + assertIsError(e); + throw new CommandError(`Unable to load package information from registry: ${e.message}`); + } - return; - } + if (!packageMetadata) { + throw new CommandError('Unable to load package information from registry.'); } - // Allow prelease versions if the CLI itself is a prerelease + // Allow prelease versions if the CLI itself is a prerelease. const allowPrereleases = !!prerelease(VERSION.full); - const versionManifests = this.#getPotentialVersionManifests(packageMetadata, allowPrereleases); + const potentialVersions = this.#getPotentialVersions(packageMetadata, allowPrereleases); - let found = false; - for (const versionManifest of versionManifests) { - // Already checked the 'latest' version - if (latestManifest?.version === versionManifest.version) { + let found; + for (const version of potentialVersions) { + const manifest = await packageManager.getManifest(`${packageName}@${version}`, { + registry, + }); + if (!manifest) { continue; } - const conflicts = await this.getPeerDependencyConflicts(versionManifest); + const conflicts = await this.getPeerDependencyConflicts(manifest); if (conflicts) { - if (options.verbose || rejectionReasons.length < DEFAULT_CONFLICT_DISPLAY_LIMIT) { + if (verbose || rejectionReasons.length < DEFAULT_CONFLICT_DISPLAY_LIMIT) { rejectionReasons.push(...conflicts); } continue; } - context.packageIdentifier = npa.resolve(versionManifest.name, versionManifest.version); - found = true; + context.packageIdentifier = npa.resolve(manifest.name, manifest.version); + found = manifest; break; } if (!found) { - let message = `Unable to find compatible package. Using 'latest' tag.`; + let message = `Unable to find compatible package.`; if (rejectionReasons.length > 0) { message += '\nThis is often because of incompatible peer dependencies.\n' + 'These versions were rejected due to the following conflicts:\n' + rejectionReasons - .slice(0, options.verbose ? undefined : DEFAULT_CONFLICT_DISPLAY_LIMIT) + .slice(0, verbose ? undefined : DEFAULT_CONFLICT_DISPLAY_LIMIT) .map((r) => ` - ${r}`) .join('\n'); } @@ -390,35 +403,25 @@ export default class AddCommandModule } } - #getPotentialVersionManifests( - packageMetadata: PackageMetadata, - allowPrereleases: boolean, - ): PackageManifest[] { + #getPotentialVersions(packageMetadata: PackageMetadata, allowPrereleases: boolean): string[] { const versionExclusions = packageVersionExclusions[packageMetadata.name]; - const versionManifests = Object.values(packageMetadata.versions).filter( - (value: PackageManifest) => { - // Prerelease versions are not stable and should not be considered by default - if (!allowPrereleases && prerelease(value.version)) { - return false; - } - // Deprecated versions should not be used or considered - if (value.deprecated) { - return false; - } - // Excluded package versions should not be considered - if ( - versionExclusions && - satisfies(value.version, versionExclusions, { includePrerelease: true }) - ) { - return false; - } - return true; - }, - ); + const versions = Object.values(packageMetadata.versions).filter((version) => { + // Prerelease versions are not stable and should not be considered by default + if (!allowPrereleases && prerelease(version)) { + return false; + } + + // Excluded package versions should not be considered + if (versionExclusions && satisfies(version, versionExclusions, { includePrerelease: true })) { + return false; + } + + return true; + }); // Sort in reverse SemVer order so that the newest compatible version is chosen - return versionManifests.sort((a, b) => compare(b.version, a.version, true)); + return versions.sort((a, b) => compare(b, a, true)); } private async loadPackageInfoTask( @@ -426,15 +429,12 @@ export default class AddCommandModule task: AddCommandTaskWrapper, options: Options, ): Promise { - const { logger } = this.context; - const { verbose, registry } = options; + const { registry } = options; let manifest; try { - manifest = await fetchPackageManifest(context.packageIdentifier.toString(), logger, { + manifest = await context.packageManager.getManifest(context.packageIdentifier.toString(), { registry, - verbose, - usingYarn: context.usingYarn, }); } catch (e) { assertIsError(e); @@ -443,6 +443,12 @@ export default class AddCommandModule ); } + if (!manifest) { + throw new CommandError( + `Unable to fetch package information for '${context.packageIdentifier}'.`, + ); + } + context.hasSchematics = !!manifest.schematics; context.savePackage = manifest['ng-add']?.save; context.collectionName = manifest.name; @@ -487,45 +493,42 @@ export default class AddCommandModule task: AddCommandTaskWrapper, options: Options, ): Promise { - const { packageManager } = this.context; const { registry } = options; + const { packageManager, packageIdentifier, savePackage } = context; // Only show if installation will actually occur task.title = 'Installing package'; - if (context.savePackage === false && packageManager.name !== PackageManager.Bun) { - // Bun has a `--no-save` option which we are using to - // install the package and not update the package.json and the lock file. + if (context.savePackage === false) { task.title += ' in temporary location'; // Temporary packages are located in a different directory // Hence we need to resolve them using the temp path - const { success, tempNodeModules } = await packageManager.installTemp( - context.packageIdentifier.toString(), - registry ? [`--registry="${registry}"`] : undefined, + const { workingDirectory } = await packageManager.acquireTempPackage( + packageIdentifier.toString(), + { + registry, + }, ); - const tempRequire = createRequire(tempNodeModules + '/'); + + const tempRequire = createRequire(workingDirectory + '/'); assert(context.collectionName, 'Collection name should always be available'); const resolvedCollectionPath = tempRequire.resolve( join(context.collectionName, 'package.json'), ); - if (!success) { - throw new CommandError('Unable to install package'); - } - context.collectionName = dirname(resolvedCollectionPath); } else { - const success = await packageManager.install( - context.packageIdentifier.toString(), - context.savePackage, - registry ? [`--registry="${registry}"`] : undefined, - undefined, + await packageManager.add( + packageIdentifier.toString(), + 'none', + savePackage !== 'dependencies', + false, + true, + { + registry, + }, ); - - if (!success) { - throw new CommandError('Unable to install package'); - } } } @@ -633,24 +636,28 @@ export default class AddCommandModule return cachedVersion; } - const { logger, root } = this.context; - let installedPackage; + const { root } = this.context; + let installedPackagePath; try { - installedPackage = this.rootRequire.resolve(join(name, 'package.json')); + installedPackagePath = this.rootRequire.resolve(join(name, 'package.json')); } catch {} - if (installedPackage) { + if (installedPackagePath) { try { - const installed = await fetchPackageManifest(dirname(installedPackage), logger); - this.#projectVersionCache.set(name, installed.version); + const installedPackage = JSON.parse( + await fs.readFile(installedPackagePath, 'utf-8'), + ) as PackageManifest; + this.#projectVersionCache.set(name, installedPackage.version); - return installed.version; + return installedPackage.version; } catch {} } let projectManifest; try { - projectManifest = await fetchPackageManifest(root, logger); + projectManifest = JSON.parse( + await fs.readFile(join(root, 'package.json'), 'utf-8'), + ) as PackageManifest; } catch {} if (projectManifest) { From 2177990c6f66e35f64e427bc6d5e59661e43cad7 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:16:25 -0400 Subject: [PATCH 2/5] perf(@angular/cli): optimize `ng add` version discovery The `ng add` command's version discovery mechanism could be slow when the `latest` tag of a package was incompatible, as it would fall back to exhaustively fetching the manifest for every available version. This commit introduces a performance optimization that uses a heuristic-based search. The new logic first identifies the latest release within each major version line and checks only those for compatibility. This dramatically reduces the number of network requests in the common case where peer dependency conflicts align with major versions. The exhaustive, version-by-version search is retained as a fallback to ensure correctness in edge cases. --- packages/angular/cli/src/commands/add/cli.ts | 98 +++++++++++++++----- 1 file changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index f20116dd1222..0dae016fba12 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -8,11 +8,11 @@ import { Listr, ListrRenderer, ListrTaskWrapper, color, figures } from 'listr2'; import assert from 'node:assert'; -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import npa from 'npm-package-arg'; -import { Range, compare, intersects, prerelease, satisfies, valid } from 'semver'; +import semver, { Range, compare, intersects, prerelease, satisfies, valid } from 'semver'; import { Argv } from 'yargs'; import { CommandModuleImplementation, @@ -358,30 +358,27 @@ export default class AddCommandModule throw new CommandError('Unable to load package information from registry.'); } - // Allow prelease versions if the CLI itself is a prerelease. - const allowPrereleases = !!prerelease(VERSION.full); + // Allow prelease versions if the CLI itself is a prerelease or locally built. + const allowPrereleases = !!prerelease(VERSION.full) || VERSION.full === '0.0.0'; const potentialVersions = this.#getPotentialVersions(packageMetadata, allowPrereleases); - let found; - for (const version of potentialVersions) { - const manifest = await packageManager.getManifest(`${packageName}@${version}`, { + // Heuristic-based search: Check the latest release of each major version first. + const majorVersions = this.#getMajorVersions(potentialVersions); + let found = await this.#findCompatibleVersion(context, majorVersions, { + registry, + verbose, + rejectionReasons, + }); + + // Exhaustive search: If no compatible major version is found, fall back to checking all versions. + if (!found) { + const checkedVersions = new Set(majorVersions); + const remainingVersions = potentialVersions.filter((v) => !checkedVersions.has(v)); + found = await this.#findCompatibleVersion(context, remainingVersions, { registry, + verbose, + rejectionReasons, }); - if (!manifest) { - continue; - } - - const conflicts = await this.getPeerDependencyConflicts(manifest); - if (conflicts) { - if (verbose || rejectionReasons.length < DEFAULT_CONFLICT_DISPLAY_LIMIT) { - rejectionReasons.push(...conflicts); - } - continue; - } - - context.packageIdentifier = npa.resolve(manifest.name, manifest.version); - found = manifest; - break; } if (!found) { @@ -403,10 +400,54 @@ export default class AddCommandModule } } + async #findCompatibleVersion( + context: AddCommandTaskContext, + versions: string[], + options: { + registry?: string; + verbose?: boolean; + rejectionReasons: string[]; + }, + ): Promise { + const { packageManager, packageIdentifier } = context; + const { registry, verbose, rejectionReasons } = options; + const packageName = packageIdentifier.name; + assert(packageName, 'Package name must be defined.'); + + for (const version of versions) { + const manifest = await packageManager.getManifest(`${packageName}@${version}`, { + registry, + }); + if (!manifest) { + continue; + } + + const conflicts = await this.getPeerDependencyConflicts(manifest); + if (conflicts) { + if (verbose || rejectionReasons.length < DEFAULT_CONFLICT_DISPLAY_LIMIT) { + rejectionReasons.push(...conflicts); + } + continue; + } + + context.packageIdentifier = npa.resolve(manifest.name, manifest.version); + + return manifest; + } + + return null; + } + #getPotentialVersions(packageMetadata: PackageMetadata, allowPrereleases: boolean): string[] { const versionExclusions = packageVersionExclusions[packageMetadata.name]; + const latestVersion = packageMetadata['dist-tags']['latest']; const versions = Object.values(packageMetadata.versions).filter((version) => { + // Latest tag has already been checked + if (latestVersion && version === latestVersion) { + return false; + } + // Prerelease versions are not stable and should not be considered by default if (!allowPrereleases && prerelease(version)) { return false; @@ -424,6 +465,19 @@ export default class AddCommandModule return versions.sort((a, b) => compare(b, a, true)); } + #getMajorVersions(versions: string[]): string[] { + const majorVersions = new Map(); + for (const version of versions) { + const major = semver.major(version); + const existing = majorVersions.get(major); + if (!existing || semver.gt(version, existing)) { + majorVersions.set(major, version); + } + } + + return [...majorVersions.values()].sort((a, b) => compare(b, a, true)); + } + private async loadPackageInfoTask( context: AddCommandTaskContext, task: AddCommandTaskWrapper, From e6b2020d3c72e4e434d07901bf37e76c12c10942 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:13:30 -0500 Subject: [PATCH 3/5] refactor(@angular/cli): adjust bun's manifest field formatter Removes the `viewCommandFieldArgFormatter` from the `bun` package manager descriptor. While `bun` supports field-filtered registry views, it is limited to a single field at a time. The current package manager abstraction requires support for multiple fields. This change ensures that the package manager abstraction for `bun` is more accurate and avoids potential issues with unsupported command arguments. --- .../cli/src/package-managers/package-manager-descriptor.ts | 1 - 1 file changed, 1 deletion(-) 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 322cf5aa2147..f48ed1e32ed7 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -243,7 +243,6 @@ export const SUPPORTED_PACKAGE_MANAGERS = { versionCommand: ['--version'], listDependenciesCommand: ['pm', 'ls', '--json'], getManifestCommand: ['pm', 'view', '--json'], - viewCommandFieldArgFormatter: (fields) => [...fields], outputParsers: { listDependencies: parseNpmLikeDependencies, getRegistryManifest: parseNpmLikeManifest, From be4474622fc14b9329729eff1213cbf798c4224f Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:12:51 -0500 Subject: [PATCH 4/5] test(@angular/cli): create `.yarnrc` for E2E registry tests with Yarn Classic The E2E tests for authenticated `ng add` operations, when run with Yarn Classic, were failing because they only generated an `.npmrc` file. Yarn Classic utilizes a `.yarnrc` file for registry authentication with a specific syntax. This change updates the `createNpmConfigForAuthentication` test utility to generate both an `.npmrc` and a `.yarnrc` file. This ensures that the authenticated E2E tests can run successfull with both NPM and Yarn Classic, improving test coverage for the package manager abstraction layer's handling of Yarn Classic's authentication mechanism. --- tests/legacy-cli/e2e/utils/registry.ts | 29 +++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/legacy-cli/e2e/utils/registry.ts b/tests/legacy-cli/e2e/utils/registry.ts index b4c1b08afcbc..fd557c116120 100644 --- a/tests/legacy-cli/e2e/utils/registry.ts +++ b/tests/legacy-cli/e2e/utils/registry.ts @@ -49,7 +49,7 @@ export async function createNpmRegistry( // Token was generated using `echo -n 'testing:s3cret' | openssl base64`. const VALID_TOKEN = `dGVzdGluZzpzM2NyZXQ=`; -export function createNpmConfigForAuthentication( +export async function createNpmConfigForAuthentication( /** * When true, the authentication token will be scoped to the registry URL. * @example @@ -70,17 +70,30 @@ export function createNpmConfigForAuthentication( const token = invalidToken ? `invalid=` : VALID_TOKEN; const registry = (getGlobalVariable('package-secure-registry') as string).replace(/^\w+:/, ''); - return writeFile( + await writeFile( '.npmrc', scopedAuthentication ? ` - ${registry}:_auth="${token}" - registry=http:${registry} - ` +${registry}/:_auth="${token}" +registry=http:${registry} +` : ` - _auth="${token}" - registry=http:${registry} - `, +_auth="${token}" +registry=http:${registry} +`, + ); + + await writeFile( + '.yarnrc', + scopedAuthentication + ? ` +${registry}/:_auth "${token}" +registry http:${registry} +` + : ` +_auth "${token}" +registry http:${registry} +`, ); } From f8c07a40bb3e0762548653d11a9e48c772d102a1 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:37:43 -0500 Subject: [PATCH 5/5] test(@angular/cli): correct unscoped auth E2E test logic The E2E test for `ng add` with unscoped registry authentication was being incorrectly executed for `pnpm`. The previous logic excluded `npm` and `bun` but erroneously assumed `pnpm` supported this authentication method, which it does not. This commit refines the `supportsUnscopedAuth` condition to be strictly limited to `yarn`. This ensures the unscoped authentication test case is now accurately targeted, only running against the package manager that supports this behavior, thereby improving the overall reliability of the secure registry tests. --- .../legacy-cli/e2e/tests/commands/add/secure-registry.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/legacy-cli/e2e/tests/commands/add/secure-registry.ts b/tests/legacy-cli/e2e/tests/commands/add/secure-registry.ts index 95218fd653d9..4a640607f8be 100644 --- a/tests/legacy-cli/e2e/tests/commands/add/secure-registry.ts +++ b/tests/legacy-cli/e2e/tests/commands/add/secure-registry.ts @@ -10,15 +10,16 @@ export default async function () { // The environment variable has priority over the .npmrc delete process.env['NPM_CONFIG_REGISTRY']; const packageManager = getActivePackageManager(); - const supportsUnscopedAuth = packageManager !== 'bun' && packageManager !== 'npm'; + const supportsUnscopedAuth = packageManager === 'yarn'; const command = ['add', '@angular/pwa', '--skip-confirmation']; - await expectFileNotToExist('public/manifest.webmanifest'); - // Works with unscoped registry authentication details if (supportsUnscopedAuth) { // Some package managers such as Bun and NPM do not support unscoped auth. await createNpmConfigForAuthentication(false); + + await expectFileNotToExist('public/manifest.webmanifest'); + await ng(...command); await expectFileToExist('public/manifest.webmanifest'); await git('clean', '-dxf'); @@ -33,7 +34,7 @@ export default async function () { await git('clean', '-dxf'); // Invalid authentication token - if (!supportsUnscopedAuth) { + if (supportsUnscopedAuth) { // Some package managers such as Bun and NPM do not support unscoped auth. await createNpmConfigForAuthentication(false, true); await expectToFail(() => ng(...command));