Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/angular/cli/src/commands/update/utilities/git.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,9 @@

import { execFileSync } from 'node:child_process';
import * as path from 'node:path';
import { findExecutableOnPath } from '../../../utilities/executable';

let cachedGitPath: string | undefined;

/**
* Execute a git command.
Expand All@@ -16,7 +19,15 @@ import * as path from 'node:path';
* @returns The output of the command.
*/
function execGit(args: string[], input?: string): string {
return execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe', input });
if (!cachedGitPath) {
const gitPath = findExecutableOnPath('git');
if (!gitPath) {
throw new Error('Git executable not found on PATH.');
}
cachedGitPath = gitPath;
}

return execFileSync(cachedGitPath, args, { encoding: 'utf8', stdio: 'pipe', input });
}

/**
Expand Down
10 changes: 9 additions & 1 deletion packages/angular/cli/src/utilities/completion.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import { getWorkspace } from '../utilities/config';
import { forceAutocomplete } from '../utilities/environment-options';
import { isTTY } from '../utilities/tty';
import { assertIsError } from './error';
import { findExecutableOnPath } from './executable';
import { askConfirmation } from './prompt';

/** Interface for the autocompletion configuration stored in the global workspace. */
Expand DownExpand Up@@ -271,7 +272,14 @@ function getShellRunCommandCandidates(shell: string, home: string): string[] | u
export function hasGlobalCliInstall(): Promise<boolean> {
// List all binaries with the `ng` name on the user's `$PATH`.
return new Promise<boolean>((resolve) => {
execFile('which', ['-a', 'ng'], (error, stdout) => {
const whichPath = findExecutableOnPath('which');
if (!whichPath) {
resolve(false);

return;
}

execFile(whichPath, ['-a', 'ng'], (error, stdout) => {
if (error) {
// No instances of `ng` on the user's `$PATH`

Expand Down
60 changes: 60 additions & 0 deletions packages/angular/cli/src/utilities/executable.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { existsSync } from 'node:fs';
import { delimiter, extname, isAbsolute, join } from 'node:path';

/**
* Searches the `PATH` environment variable for a given executable binary name.
* On Windows, checks extensions in `PATHEXT` (e.g. `.exe`, `.cmd`) if no extension is given.
* Returns the absolute path of the binary if found on `PATH`, or `undefined` if not found.
*
* This prevents `execFileSync` / `spawn` from implicitly resolving executables
* relative to `process.cwd()` on Windows when passed bare command names.
*
* @param binaryName Name of the binary to search for (e.g. 'git').
* @returns The absolute path to the binary if found on `PATH`, or `undefined`.
*/
export function findExecutableOnPath(binaryName: string): string | undefined {
const envPath = process.env.PATH || process.env.Path || '';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is there a specific platform which uses ${Path} as distinct from ${PATH}? I've never seen that before.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Prefer ?? to ||.

if (!envPath) {
return undefined;
}

const isWindows = process.platform === 'win32';
const pathExt = process.env.PATHEXT
? process.env.PATHEXT.split(delimiter)
: ['.com', '.exe', '.bat', '.cmd'];
Comment on lines +30 to +32

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL about PATHEXT.


const hasExt = isWindows && extname(binaryName) !== '';
const extensions = isWindows && !hasExt ? pathExt : [''];

for (const rawDir of envPath.split(delimiter)) {
if (!rawDir) {
continue;
}

const dir = rawDir.startsWith('"') && rawDir.endsWith('"') ? rawDir.slice(1, -1) : rawDir;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Do we have to deal with escaping? The fact that we need to parse quotes makes me generally uncomfortable that there's more hidden complexity here.

if (!isAbsolute(dir)) {
continue;
}

for (const ext of extensions) {
const candidate = join(dir, binaryName + ext);
try {
if (existsSync(candidate)) {
return candidate;
}
} catch {
// Ignore file system errors (e.g. invalid path or permission error)
}
}
}

return undefined;
}
52 changes: 52 additions & 0 deletions packages/angular/cli/src/utilities/executable_spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { dirname } from 'node:path';
import { findExecutableOnPath } from './executable';

describe('findExecutableOnPath', () => {
it('should find executable on PATH when it exists', () => {
// 'node' binary should be present on PATH in any Node test environment
const nodePath = findExecutableOnPath('node');
expect(nodePath).toBeDefined();
expect(nodePath).toContain('node');
});

it('should return undefined when binary does not exist on PATH', () => {
const nonExistentPath = findExecutableOnPath('non_existent_binary_123456789');
expect(nonExistentPath).toBeUndefined();
});

it('should correctly handle PATH entries wrapped in double quotes', () => {
const nodePath = findExecutableOnPath('node');
if (!nodePath) {
return;
}

const originalPath = process.env.PATH;
try {
const dir = dirname(nodePath);
process.env.PATH = `"${dir}"`;
const resolved = findExecutableOnPath('node');
expect(resolved).toBeDefined();
} finally {
process.env.PATH = originalPath;
}
});

it('should ignore relative PATH entries', () => {
const originalPath = process.env.PATH;
try {
process.env.PATH = '.';
const resolved = findExecutableOnPath('node');
expect(resolved).toBeUndefined();
} finally {
process.env.PATH = originalPath;
}
});
});