From 530f6a9548f193daa8c0cb37d5ffb31ad6a00723 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:49:53 -0500 Subject: [PATCH] refactor(@angular/cli): implement lazy validation for package manager This changes the package manager initialization to only throw errors when the binary is actually required for an operation. This allows CLI commands that do not depend on the package manager binary to function even if the configured package manager is missing. --- .../cli/src/package-managers/factory.ts | 6 ++-- .../src/package-managers/package-manager.ts | 22 +++++++++++++++ .../package-managers/package-manager_spec.ts | 28 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/angular/cli/src/package-managers/factory.ts b/packages/angular/cli/src/package-managers/factory.ts index e3635ae7b30f..ed15fcb8a05c 100644 --- a/packages/angular/cli/src/package-managers/factory.ts +++ b/packages/angular/cli/src/package-managers/factory.ts @@ -145,17 +145,18 @@ export async function createPackageManager(options: { } // Do not verify if the package manager is installed during a dry run. + let initializationError: Error | undefined; if (!dryRun && !version) { try { version = await getPackageManagerVersion(host, cwd, name, logger); } catch { if (source === 'default') { - throw new Error( + initializationError = new Error( `'${DEFAULT_PACKAGE_MANAGER}' was selected as the default package manager, but it is not installed or` + ` cannot be found in the PATH. Please install '${DEFAULT_PACKAGE_MANAGER}' to continue.`, ); } else { - throw new Error( + initializationError = new Error( `The project is configured to use '${name}', but it is not installed or cannot be` + ` found in the PATH. Please install '${name}' to continue.`, ); @@ -168,6 +169,7 @@ export async function createPackageManager(options: { logger, tempDirectory, version, + initializationError, }); logger?.debug(`Successfully created PackageManager for '${name}'.`); diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 7be28fc108f2..fd3b0a663a79 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -71,6 +71,12 @@ export interface PackageManagerOptions { * instead of running the version command. */ version?: string; + + /** + * An error that occurred during the initialization of the package manager. + * If provided, this error will be thrown when attempting to execute any command. + */ + initializationError?: Error; } /** @@ -84,6 +90,7 @@ export interface PackageManagerOptions { export class PackageManager { readonly #manifestCache = new Map(); readonly #metadataCache = new Map(); + readonly #initializationError?: Error; #dependencyCache: Map | null = null; #version: string | undefined; @@ -104,6 +111,7 @@ export class PackageManager { throw new Error('A logger must be provided when dryRun is enabled.'); } this.#version = options.version; + this.#initializationError = options.initializationError; } /** @@ -113,6 +121,18 @@ export class PackageManager { return this.descriptor.binary; } + /** + * Ensures that the package manager is installed and available in the PATH. + * If it is not, this method will throw an error with instructions on how to install it. + * + * @throws {Error} If the package manager is not installed. + */ + ensureInstalled(): void { + if (this.#initializationError) { + throw this.#initializationError; + } + } + /** * A private method to lazily populate the dependency cache. * This is a performance optimization to avoid running `npm list` multiple times. @@ -142,6 +162,8 @@ export class PackageManager { args: readonly string[], options: { timeout?: number; registry?: string; cwd?: string } = {}, ): Promise<{ stdout: string; stderr: string }> { + this.ensureInstalled(); + const { registry, cwd, ...runOptions } = options; const finalArgs = [...args]; let finalEnv: Record | undefined; diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index 2482349b323d..802f50fa66ab 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -51,4 +51,32 @@ describe('PackageManager', () => { expect(runCommandSpy).not.toHaveBeenCalled(); }); }); + + describe('initializationError', () => { + it('should throw initializationError when running commands', async () => { + const error = new Error('Not installed'); + const pm = new PackageManager(host, '/tmp', descriptor, { initializationError: error }); + + expect(() => pm.ensureInstalled()).toThrow(error); + await expectAsync(pm.getVersion()).toBeRejectedWith(error); + await expectAsync(pm.install()).toBeRejectedWith(error); + await expectAsync(pm.add('foo', 'none', false, false, false)).toBeRejectedWith(error); + }); + + it('should not throw initializationError for operations that do not require the binary', async () => { + const error = new Error('Not installed'); + const pm = new PackageManager(host, '/tmp', descriptor, { initializationError: error }); + + // Mock readFile for getManifest directory case + spyOn(host, 'readFile').and.resolveTo('{"name": "foo", "version": "1.0.0"}'); + + // Should not throw + const manifest = await pm.getManifest({ + type: 'directory', + fetchSpec: '/tmp/foo', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + expect(manifest).toEqual({ name: 'foo', version: '1.0.0' }); + }); + }); });