Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check all executable extensions on windows #3949

Merged
merged 6 commits into from
Apr 24, 2019
Merged
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
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))));
}