UNPKG

@cedx/which

Version:

Find the instances of an executable in the system path. Like the `which` Linux command.

134 lines (114 loc) 4.09 kB
import type {Stats} from "node:fs"; import {stat} from "node:fs/promises"; import {delimiter, extname, resolve} from "node:path"; import process from "node:process"; /** * Finds the instances of an executable in the system path. */ export class Finder { /** * The list of executable file extensions. */ readonly extensions: Set<string>; /** * The list of system paths. */ readonly paths: Set<string>; /** * Creates a new finder. * @param options An object providing values to initialize this instance. */ constructor(options: FinderOptions = {}) { let {extensions = [], paths = []} = options; if (!extensions.length) { const pathExt = process.env.PATHEXT ?? ""; extensions = pathExt ? pathExt.split(";") : [".exe", ".cmd", ".bat", ".com"]; } if (!paths.length) { const pathEnv = process.env.PATH ?? ""; paths = pathEnv ? pathEnv.split(Finder.isWindows ? ";" : delimiter) : []; } this.extensions = new Set(extensions.map(extension => extension.toLowerCase())); this.paths = new Set(paths.map(item => item.replace(/^"|"$/g, "")).filter(item => item.length)); } /** * Value indicating whether the current platform is Windows. */ static get isWindows(): boolean { return process.platform == "win32" || ["cygwin", "msys"].includes(process.env.OSTYPE ?? ""); } /** * Finds the instances of an executable in the system path. * @param command The command to be resolved. * @returns The paths of the executables found. */ async *find(command: string): AsyncGenerator<string, void, void> { for (const directory of this.paths) yield* this.#findExecutables(directory, command); } /** * Gets a value indicating whether the specified file is executable. * @param file The path of the file to be checked. * @returns `true` if the specified file is executable, otherwise `false`. */ async isExecutable(file: string): Promise<boolean> { try { const fileStats = await stat(file); return fileStats.isFile() && (Finder.isWindows ? this.#checkFileExtension(file) : this.#checkFilePermissions(fileStats)); } catch { return false; } } /** * Checks that the specified file is executable according to the executable file extensions. * @param file The path of the file to be checked. * @returns `true` if the specified file is executable, otherwise `false`. */ #checkFileExtension(file: string): boolean { return this.extensions.has(extname(file).toLowerCase()); } /** * Checks that the specified file is executable according to its permissions. * @param stats A reference to the file to be checked. * @returns `true` if the specified file is executable, otherwise `false`. */ #checkFilePermissions(stats: Stats): boolean { // Others. const perms = stats.mode; if (perms & 0o001) return true; // Group. const gid = typeof process.getgid == "function" ? process.getgid() : -1; if (perms & 0o010) return gid == stats.gid; // Owner. const uid = typeof process.getuid == "function" ? process.getuid() : -1; if (perms & 0o100) return uid == stats.uid; // Root. return perms & (0o100 | 0o010) ? uid == 0 : false; } /** * Finds the instances of an executable in the specified directory. * @param directory The directory path. * @param command The command to be resolved. * @returns The paths of the executables found. */ async *#findExecutables(directory: string, command: string): AsyncGenerator<string, void, void> { const extensions = Finder.isWindows ? this.extensions : []; for (const extension of ["", ...extensions]) { const resolvedPath = resolve(directory, `${command}${extension}`); if (await this.isExecutable(resolvedPath)) yield resolvedPath; } } } /** * Defines the options of a {@link Finder} instance. */ export type FinderOptions = Partial<{ /** * The list of executable file extensions. */ extensions: string[]; /** * The list of system paths. */ paths: string[]; }>;