Skip to content

Commit fe4c310

Browse files
committed
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.
1 parent 346973a commit fe4c310

File tree

3 files changed

+54
-2
lines changed

3 files changed

+54
-2
lines changed

packages/angular/cli/src/package-managers/factory.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,17 +145,18 @@ export async function createPackageManager(options: {
145145
}
146146

147147
// Do not verify if the package manager is installed during a dry run.
148+
let initializationError: Error | undefined;
148149
if (!dryRun && !version) {
149150
try {
150151
version = await getPackageManagerVersion(host, cwd, name, logger);
151152
} catch {
152153
if (source === 'default') {
153-
throw new Error(
154+
initializationError = new Error(
154155
`'${DEFAULT_PACKAGE_MANAGER}' was selected as the default package manager, but it is not installed or` +
155156
` cannot be found in the PATH. Please install '${DEFAULT_PACKAGE_MANAGER}' to continue.`,
156157
);
157158
} else {
158-
throw new Error(
159+
initializationError = new Error(
159160
`The project is configured to use '${name}', but it is not installed or cannot be` +
160161
` found in the PATH. Please install '${name}' to continue.`,
161162
);
@@ -168,6 +169,7 @@ export async function createPackageManager(options: {
168169
logger,
169170
tempDirectory,
170171
version,
172+
initializationError,
171173
});
172174

173175
logger?.debug(`Successfully created PackageManager for '${name}'.`);

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ export interface PackageManagerOptions {
7171
* instead of running the version command.
7272
*/
7373
version?: string;
74+
75+
/**
76+
* An error that occurred during the initialization of the package manager.
77+
* If provided, this error will be thrown when attempting to execute any command.
78+
*/
79+
initializationError?: Error;
7480
}
7581

7682
/**
@@ -84,6 +90,7 @@ export interface PackageManagerOptions {
8490
export class PackageManager {
8591
readonly #manifestCache = new Map<string, PackageManifest | null>();
8692
readonly #metadataCache = new Map<string, PackageMetadata | null>();
93+
readonly #initializationError?: Error;
8794
#dependencyCache: Map<string, InstalledPackage> | null = null;
8895
#version: string | undefined;
8996

@@ -104,6 +111,7 @@ export class PackageManager {
104111
throw new Error('A logger must be provided when dryRun is enabled.');
105112
}
106113
this.#version = options.version;
114+
this.#initializationError = options.initializationError;
107115
}
108116

109117
/**
@@ -113,6 +121,18 @@ export class PackageManager {
113121
return this.descriptor.binary;
114122
}
115123

124+
/**
125+
* Ensures that the package manager is installed and available in the PATH.
126+
* If it is not, this method will throw an error with instructions on how to install it.
127+
*
128+
* @throws {Error} If the package manager is not installed.
129+
*/
130+
ensureInstalled(): void {
131+
if (this.#initializationError) {
132+
throw this.#initializationError;
133+
}
134+
}
135+
116136
/**
117137
* A private method to lazily populate the dependency cache.
118138
* This is a performance optimization to avoid running `npm list` multiple times.
@@ -142,6 +162,8 @@ export class PackageManager {
142162
args: readonly string[],
143163
options: { timeout?: number; registry?: string; cwd?: string } = {},
144164
): Promise<{ stdout: string; stderr: string }> {
165+
this.ensureInstalled();
166+
145167
const { registry, cwd, ...runOptions } = options;
146168
const finalArgs = [...args];
147169
let finalEnv: Record<string, string> | undefined;

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,32 @@ describe('PackageManager', () => {
5151
expect(runCommandSpy).not.toHaveBeenCalled();
5252
});
5353
});
54+
55+
describe('initializationError', () => {
56+
it('should throw initializationError when running commands', async () => {
57+
const error = new Error('Not installed');
58+
const pm = new PackageManager(host, '/tmp', descriptor, { initializationError: error });
59+
60+
expect(() => pm.ensureInstalled()).toThrow(error);
61+
await expectAsync(pm.getVersion()).toBeRejectedWith(error);
62+
await expectAsync(pm.install()).toBeRejectedWith(error);
63+
await expectAsync(pm.add('foo', 'none', false, false, false)).toBeRejectedWith(error);
64+
});
65+
66+
it('should not throw initializationError for operations that do not require the binary', async () => {
67+
const error = new Error('Not installed');
68+
const pm = new PackageManager(host, '/tmp', descriptor, { initializationError: error });
69+
70+
// Mock readFile for getManifest directory case
71+
spyOn(host, 'readFile').and.resolveTo('{"name": "foo", "version": "1.0.0"}');
72+
73+
// Should not throw
74+
const manifest = await pm.getManifest({
75+
type: 'directory',
76+
fetchSpec: '/tmp/foo',
77+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
78+
} as any);
79+
expect(manifest).toEqual({ name: 'foo', version: '1.0.0' });
80+
});
81+
});
5482
});

0 commit comments

Comments
 (0)