Skip to content

Commit 62cbca6

Browse files
committed
fix(@angular/cli): resolve executables strictly from PATH
Update executable invocation logic to resolve system binaries (such as `git` and `which`) strictly from the `PATH` environment variable. This prevents bare command names passed to `execFileSync` / `execFile` from implicitly searching and resolving binaries relative to `process.cwd()` on Windows. Fixes #33755
1 parent b6ed402 commit 62cbca6

4 files changed

Lines changed: 87 additions & 2 deletions

File tree

packages/angular/cli/src/commands/update/utilities/git.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
import { execFileSync } from 'node:child_process';
1010
import * as path from 'node:path';
11+
import { findExecutableOnPath } from '../../../utilities/executable';
12+
13+
let cachedGitPath: string | undefined;
1114

1215
/**
1316
* Execute a git command.
@@ -16,7 +19,9 @@ import * as path from 'node:path';
1619
* @returns The output of the command.
1720
*/
1821
function execGit(args: string[], input?: string): string {
19-
return execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe', input });
22+
cachedGitPath ??= findExecutableOnPath('git') ?? 'git';
23+
24+
return execFileSync(cachedGitPath, args, { encoding: 'utf8', stdio: 'pipe', input });
2025
}
2126

2227
/**

packages/angular/cli/src/utilities/completion.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getWorkspace } from '../utilities/config';
1616
import { forceAutocomplete } from '../utilities/environment-options';
1717
import { isTTY } from '../utilities/tty';
1818
import { assertIsError } from './error';
19+
import { findExecutableOnPath } from './executable';
1920
import { askConfirmation } from './prompt';
2021

2122
/** Interface for the autocompletion configuration stored in the global workspace. */
@@ -271,7 +272,8 @@ function getShellRunCommandCandidates(shell: string, home: string): string[] | u
271272
export function hasGlobalCliInstall(): Promise<boolean> {
272273
// List all binaries with the `ng` name on the user's `$PATH`.
273274
return new Promise<boolean>((resolve) => {
274-
execFile('which', ['-a', 'ng'], (error, stdout) => {
275+
const whichPath = findExecutableOnPath('which') ?? 'which';
276+
execFile(whichPath, ['-a', 'ng'], (error, stdout) => {
275277
if (error) {
276278
// No instances of `ng` on the user's `$PATH`
277279

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { existsSync } from 'node:fs';
10+
import { delimiter, extname, join } from 'node:path';
11+
12+
/**
13+
* Searches the `PATH` environment variable for a given executable binary name.
14+
* On Windows, checks extensions in `PATHEXT` (e.g. `.exe`, `.cmd`) if no extension is given.
15+
* Returns the absolute path of the binary if found on `PATH`, or `undefined` if not found.
16+
*
17+
* This prevents `execFileSync` / `spawn` from implicitly resolving executables
18+
* relative to `process.cwd()` on Windows when passed bare command names.
19+
*
20+
* @param binaryName Name of the binary to search for (e.g. 'git').
21+
* @returns The absolute path to the binary if found on `PATH`, or `undefined`.
22+
*/
23+
export function findExecutableOnPath(binaryName: string): string | undefined {
24+
const envPath = process.env.PATH || process.env.Path || '';
25+
if (!envPath) {
26+
return undefined;
27+
}
28+
29+
const isWindows = process.platform === 'win32';
30+
const pathExt = process.env.PATHEXT
31+
? process.env.PATHEXT.split(delimiter)
32+
: ['.com', '.exe', '.bat', '.cmd'];
33+
34+
const hasExt = isWindows && extname(binaryName) !== '';
35+
const extensions = isWindows && !hasExt ? pathExt : [''];
36+
37+
for (const dir of envPath.split(delimiter)) {
38+
if (!dir) {
39+
continue;
40+
}
41+
42+
for (const ext of extensions) {
43+
const candidate = join(dir, binaryName + ext);
44+
try {
45+
if (existsSync(candidate)) {
46+
return candidate;
47+
}
48+
} catch {
49+
// Ignore file system errors (e.g. invalid path or permission error)
50+
}
51+
}
52+
}
53+
54+
return undefined;
55+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { findExecutableOnPath } from './executable';
10+
11+
describe('findExecutableOnPath', () => {
12+
it('should find executable on PATH when it exists', () => {
13+
// 'node' binary should be present on PATH in any Node test environment
14+
const nodePath = findExecutableOnPath('node');
15+
expect(nodePath).toBeDefined();
16+
expect(nodePath).toContain('node');
17+
});
18+
19+
it('should return undefined when binary does not exist on PATH', () => {
20+
const nonExistentPath = findExecutableOnPath('non_existent_binary_123456789');
21+
expect(nonExistentPath).toBeUndefined();
22+
});
23+
});

0 commit comments

Comments
 (0)