Skip to content

Commit

Permalink
fix(subprocess): check all executable extensions on windows (#3949)
Browse files Browse the repository at this point in the history
  • Loading branch information
tlancina authored and imhoffd committed Apr 24, 2019
1 parent 89ffd21 commit e1cf74e
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 26 deletions.
1 change: 1 addition & 0 deletions packages/@ionic/utils-subprocess/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@ionic/utils-fs": "1.2.0",
"@ionic/utils-process": "0.1.0",
"@ionic/utils-stream": "0.0.1",
"@ionic/utils-terminal": "0.0.1",
"cross-spawn": "^6.0.5",
"debug": "^4.0.0",
"tslib": "^1.9.0"
Expand Down
49 changes: 45 additions & 4 deletions packages/@ionic/utils-subprocess/src/__tests__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ describe('@ionic/utils-subprocess', () => {
const mock_os = { homedir: () => '/home/me' };
jest.mock('cross-spawn', () => mockCrossSpawn);
jest.mock('os', () => mock_os);
const mock_path_posix = path.posix;
jest.mock('path', () => mock_path_posix);
const { Subprocess, SubprocessError } = require('../');

beforeEach(() => {
Expand Down Expand Up @@ -116,9 +114,9 @@ describe('@ionic/utils-subprocess', () => {
});

it('should alter PATH with tildes if provided', async () => {
const PATH = '/path/to/bin:~/bin';
const PATH = ['/path/to/bin', '~/bin'].join(path.delimiter);
const cmd = new Subprocess('cmd', [], { env: { PATH } });
expect(cmd.options.env.PATH).toEqual('/path/to/bin:/home/me/bin');
expect(cmd.options.env.PATH).toEqual(['/path/to/bin', '/home/me/bin'].join(path.delimiter));
});

it('should bashify command and args', async () => {
Expand Down Expand Up @@ -402,4 +400,47 @@ describe('@ionic/utils-subprocess', () => {

});

describe('which/findExecutables', () => {
describe('windows', () => {
const originalPATHEXT = process.env.PATHEXT;
const mockBinDir1 = 'C:\\path\\to\\nodejs';
const mockBinDir2 = 'C:\\other\\path\\to\\nodejs';
const mockBinPath1 = path.win32.join(mockBinDir1, 'node.exe');
const mockBinPath2 = path.win32.join(mockBinDir2, 'node.cmd');
const mockPATH = `C:\\my\\home\\dir;C:\\some\\other\\dir;${mockBinDir1};${mockBinDir2}`;

process.env.PATHEXT = '.COM;.EXE;.BAT;.CMD';

afterAll(() => {
process.env.PATHEXT = originalPATHEXT;
});

jest.resetModules();
jest.mock('path', () => path.win32);
jest.mock('@ionic/utils-terminal', () => ({ TERMINAL_INFO: { windows: true } }));
jest.doMock('@ionic/utils-fs', () => ({
isExecutableFile: async (filePath: string) => filePath === mockBinPath1 || filePath === mockBinPath2
}));

const { which, findExecutables } = require('../');

it('should find the first executable in PATH', async () => {
const result = await which('node', { PATH: mockPATH });
expect(result).toEqual(mockBinPath1);
});

it('should not append an extension if already provided', async () => {
const result = await which('node.cmd', { PATH: mockPATH });
expect(result).toEqual(mockBinPath2);
});

it('should find all executables in PATH', async () => {
const result = await findExecutables('node', { PATH: mockPATH });
expect(result.length).toEqual(2);
expect(result[0]).toEqual(mockBinPath1);
expect(result[1]).toEqual(mockBinPath2);
});
});
});

});
51 changes: 29 additions & 22 deletions packages/@ionic/utils-subprocess/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { filter, reduce } from '@ionic/utils-array';
import { concurrentFilter, map } from '@ionic/utils-array';
import { isExecutableFile } from '@ionic/utils-fs';
import { createProcessEnv, getPathParts } from '@ionic/utils-process';
import { WritableStreamBuffer } from '@ionic/utils-stream';
import { TERMINAL_INFO } from '@ionic/utils-terminal';
import { ChildProcess, ForkOptions, SpawnOptions, fork as _fork } from 'child_process';
import * as crossSpawn from 'cross-spawn';
import * as os from 'os';
Expand Down Expand Up @@ -254,44 +255,32 @@ export function fork(modulePath: string, args: readonly string[] = [], options:

export interface WhichOptions {
PATH?: string;
PATHEXT?: string;
}

const DEFAULT_PATHEXT = TERMINAL_INFO.windows ? '.COM;.EXE;.BAT;.CMD' : undefined;

/**
* Find the first instance of a program in PATH.
*
* If `program` contains a path separator, this function will merely return it.
*
* @param program A command name, such as `ionic`
*/
export async function which(program: string, { PATH = process.env.PATH || '' }: WhichOptions = {}): Promise<string> {
export async function which(program: string, { PATH = process.env.PATH, PATHEXT = process.env.PATHEXT || DEFAULT_PATHEXT }: WhichOptions = {}): Promise<string> {
if (program.includes(pathlib.sep)) {
return program;
}

const pathParts = getPathParts(PATH);

const value = await reduce<string, string | null>(pathParts, async (acc, v) => {
// acc is no longer null, so we found the first match already
if (acc) {
return acc;
}

const p = pathlib.join(v, program);

if (await isExecutableFile(p)) {
return p;
}
const results = await _findExecutables(program, { PATH });

return null; // tslint:disable-line:no-null-keyword
}, null); // tslint:disable-line:no-null-keyword

if (!value) {
if (!results.length) {
const err: NodeJS.ErrnoException = new Error(`${program} cannot be found within PATH`);
err.code = 'ENOENT';
throw err;
}

return value;
return results[0];
}

/**
Expand All @@ -302,10 +291,28 @@ export async function which(program: string, { PATH = process.env.PATH || '' }:
*
* @param program A command name, such as `ionic`
*/
export async function findExecutables(program: string, { PATH = process.env.PATH || '' }: WhichOptions = {}): Promise<string[]> {
export async function findExecutables(program: string, { PATH = process.env.PATH, PATHEXT = process.env.PATHEXT || DEFAULT_PATHEXT }: WhichOptions = {}): Promise<string[]> {
if (program.includes(pathlib.sep)) {
return [program];
}

return filter(getPathParts(PATH).map(p => pathlib.join(p, program)), async p => isExecutableFile(p));
return _findExecutables(program, { PATH });
}

async function _findExecutables(program: string, { PATH = process.env.PATH, PATHEXT = process.env.PATHEXT || DEFAULT_PATHEXT }: WhichOptions = {}): Promise<string[]> {
const pathParts = getPathParts(PATH);
let programNames: string[];

// if windows, cycle through all possible executable extensions
// ex: node.exe, npm.cmd, etc.
if (TERMINAL_INFO.windows) {
const exts = getPathParts(PATHEXT).map(ext => ext.toLowerCase());
// don't append extensions if one has already been provided
programNames = exts.includes(pathlib.extname(program).toLowerCase()) ? [program] : exts.map(ext => program + ext);
} else {
programNames = [program];
}

return ([] as string[]).concat(...await map(programNames, async (programName): Promise<string[]> =>
concurrentFilter(pathParts.map(p => pathlib.join(p, programName)), async p => isExecutableFile(p))));
}

0 comments on commit e1cf74e

Please sign in to comment.