UNPKG

@villedemontreal/general-utils

Version:
583 lines (518 loc) 16.9 kB
import { spawn, StdioOptions } from 'child_process'; import * as fs from 'fs'; import * as getPort from 'get-port'; import * as pathUtils from 'path'; import { rimraf } from 'rimraf'; import * as tsconfig from 'tsconfig-extends'; import { constants } from './config/constants'; import { isArray, isDate, isEqual, isFunction, isNil, isObject, isString, trimEnd } from 'lodash'; /** * General utilities */ export class Utils { private tscCompilerOptionsParams: string[]; /** * Promisified setTimeout() utility function. * * @param ms The number of milliseconds to sleep for. */ public async sleep(ms: number): Promise<void> { await new Promise((resolve, reject) => { setTimeout(resolve, ms); }); } /** * Checks if a String is null, undefined, * empty, or contains only whitespaces. */ public isBlank(str: string): boolean { if (str === null || str === undefined) { return true; } if (!(typeof str === 'string')) { return false; } return str.trim() === ''; } /** * A better version of "isNaN()". * For example, an empty string is NOT considered as a * number. */ public isNaNSafe(value: any): boolean { if (isNaN(value) || this.isBlank(value)) { return true; } const type = typeof value; if (type !== 'string' && type !== 'number') { return true; } return false; } /** * If the "el" is not undefined, returns it as is. * If the el is undefined, returns NULL. * * This is useful when undefined is not acceptable * but null is. */ public getDefinedOrNull(el: any): any { if (el === undefined) { return null; } return el; } /** * Checks if a value is an integer. * * If you want to use the includeZero parameter without * using the positiveOnly parameter, we suggest to pass * undefined as a second parameter. * * After a positive check, we suggest to * pass the value with the Number object (Number(value)) * to "clean" it, e.g., getting rid of unmeaningful * decimal zeros or whitespaces. */ public isIntegerValue(value: any, positiveOnly = false, includeZero = true): boolean { if (this.isNaNSafe(value)) { return false; } // Convert to Number, if not already one const asNumber = Number(value); if (positiveOnly && asNumber < 0) { return false; } if (!includeZero && asNumber === 0) { return false; } // Busts integer safe limits if (asNumber > Number.MAX_SAFE_INTEGER || asNumber < Number.MIN_SAFE_INTEGER) { return false; } // If there were decimals but "0" only, it is // still considered as an Integer, and Number(value) // still have stripped those decimals.... if ((asNumber + '').indexOf('.') > -1) { return false; } return true; } /** * Converts a string to a boolean. * * The string is TRUE only if it is * "true" (case insensitive) or "1" * (the *number* 1 is also accepted) * * Otherwise, it is considered as FALSE. */ public stringToBoolean(str: string): boolean { if (str === null || str === undefined) { return false; } let strClean = str; if (typeof strClean === 'number') { strClean = str + ''; } else if (typeof strClean !== 'string') { return false; } strClean = strClean.toLowerCase(); if (strClean === 'true' || strClean === '1') { return true; } return false; } /** * Make sure a file is safe to delete, that is: * - It is truly * - It is not the path of a root directory or file */ public isSafeToDelete(path: string): boolean { if (!path) { return false; } let pathClean = path; pathClean = pathUtils.normalize(pathClean); pathClean = pathClean.replace(/\\/g, '/'); pathClean = trimEnd(pathClean, '/ '); return (pathClean.match(/\//g) || []).length > 1; } /** * Checks if a path points to an existing directory. * * @returns true if the path points to an existing * directory (not a file). */ public isDir(dirPath: string): boolean { if (!dirPath || !fs.existsSync(dirPath)) { return false; } return fs.lstatSync(dirPath).isDirectory(); } /** * Checks if a directory is empty. * * @returns true if the directory is empty or * doesn't exist. Returns false if the path * points to a *file* or to a directory that * is not empty. */ public isDirEmpty(dirPath: string): boolean { if (fs.existsSync(dirPath)) { if (this.isDir(dirPath)) { const files = fs.readdirSync(dirPath); return !files || files.length === 0; } return false; } return true; } /** * Deletes a file, promisified and in a * solid way. * * You can't delete a root file using this function. */ public deleteFile(filePath: string) { if (!this.isSafeToDelete(filePath)) { throw new Error("Unsafe file to delete. A file to delete can't be at the root."); } return rimraf(filePath); } /** * Deletes a directory, promisified and in a * solid way. * * You can't delete a root directory using this function. */ public async deleteDir(dirPath: string) { if (!this.isSafeToDelete(dirPath)) { throw new Error("Unsafe dir to delete. A dir to delete can't be at the root."); } try { return rimraf(dirPath); } catch (err) { // ========================================== // Try recursively as rimraf may sometimes // fail in infrequent situations... // ========================================== await this.clearDir(dirPath); return rimraf(dirPath); } } /** * Clears a directory, promisified and in a * solid way. * * You can't clear a root directory using this function. */ public async clearDir(dirPath: string) { if (!this.isSafeToDelete(dirPath)) { throw new Error("Unsafe dir to clear. A dir to clear can't be at the root."); } // NOTE: I had to replace the globby module with fs.readdir, because globby was not // listing the folders any more! return new Promise<void>((resolve, reject) => { fs.readdir(dirPath, async (err, paths: string[]) => { if (err) { reject(err); return; } for (const path of paths) { const filePath = pathUtils.join(dirPath, path); if (fs.lstatSync(filePath).isDirectory()) { await this.deleteDir(filePath); } else { await this.deleteFile(filePath); } } resolve(); }); }); } protected get tscCompilerOptions(): string[] { if (!this.tscCompilerOptionsParams) { this.tscCompilerOptionsParams = []; const compilerOptions = tsconfig.load_file_sync(constants.appRoot + '/tsconfig.json'); for (const key of Object.keys(compilerOptions)) { // ========================================== // TS6064: Options 'plugins', 'composite' can only be specified in 'tsconfig.json' file. // ========================================== if (['plugins', 'composite'].includes(key)) { continue; } // ========================================== // "--forceConsistentCasingInFileNames" sometimes // causes problems when running tests inside VSCode : // http://stackissue.com/Microsoft/vscode/lower-case-drive-letter-in-open-new-command-prompt-command-on-windows-9448.html // ========================================== if (key === 'forceConsistentCasingInFileNames' && process.env.ide === 'true') { compilerOptions[key] = false; } this.tscCompilerOptionsParams.push('--' + key); this.tscCompilerOptionsParams.push(compilerOptions[key]); } } return this.tscCompilerOptionsParams; } /** * Runs the "tsc" command on specific files * using the same options than the ones found * in the "tsconfig.json" file of the project. * * @param files the absolute paths of the files to compile. * @outdir allows us to redirect the output directory */ public async tsc(files: string[]): Promise<void> { if (!files) { return; } const cmd = 'node'; const tscCmd = constants.findModulePath('node_modules/typescript/lib/tsc.js'); const args = [tscCmd].concat(this.tscCompilerOptions).concat(files); await this.execPromisified(cmd, args); } /** * Creates a "range", an array of continuous integers, from * "start" to "end". * Both "start" and "end" are inclusive. */ public range = (start: number, end: number): number[] => { return [...Array(1 + end - start).keys()].map((v) => start + v); }; /** * Returns a free port. */ public async findFreePort(): Promise<number> { return await getPort(); } /** * Validates if the object is of type Date and * is valid. Deserializing an invalid string * to a Date may result in a Date, but which is invalid. * Then loadash's isDate(d) is not enough to detect * if the result is valid or not. This function is. */ public isValidDate(date: any): boolean { if (this.isBlank(date)) { return false; } return Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date.getTime()); } /** * To be used as a "@Transform" decorator on a * Date field, when using the "class-transformer" * library. For example : * * @Transform(utils.dateTransformer) * public created: Date; * * When using what class-transformer provides * by default ("@Type(() => Date)") there are cases * that are problematic : * - "123" and "true" :result in valid dates! * * Note : *only* use this decorator on the Date field, not * in addition to "@Type(() => Date)"! */ public dateTransformer = (value: any): Date => { let date: Date; if (isNil(value)) { return null; } if (isDate(value)) { date = value; } else if (!isString(value) || utils.isBlank(value)) { // ========================================== // Makes sure it's an invalid date! // Because by default, true and 123 are accepted, // and are transform to valid dates! // ========================================== date = new Date(`invalid!`); } else { date = new Date(value); } return date; }; /** * Throws an Error for the specified element as type "never". * * To be used in the "default" section of a switch/case statement. * This allows the validation at compile time that all elements of the * swtiched element are managed (as long as it is a discret type such * as an enum). */ public throwNotManaged = (messagePrefix: string, element: never): void => { throw new Error(`${messagePrefix}: ${element}`); }; /** * Returns TRUE if the parameter is an object but is not an array, * a Date or a function * By default, _.isObject(x) from Lodash also returns TRUE for * an Array, for a Date and a function. * * Returns FALSE for null/undefined. */ public isObjectStrict = (val: any): boolean => { if (!val) { return false; } return isObject(val) && !isArray(val) && !isDate(val) && !isFunction(val); }; /** * Returns TRUE if the specified "array" contains at least one object * that has the specified "key" and if the value associated with that key is * strictly equals to the specified "value". */ public arrayContainsObjectWithKeyEqualsTo = (array: any[], key: string, value: any): boolean => { if (!array || !isArray(array) || array.length < 1) { return false; } for (const obj of array) { if (this.isObjectStrict(obj) && isEqual(obj[key], value)) { return true; } } return false; }; /** * @deprecated Use `exec()` instead. */ public execPromisified( command: string, args: string[], dataHandler: (stdoutData: string, stderrData: string) => void = null, useShellOption = false, ): Promise<void> { return this.exec(command, args, { outputHandler: dataHandler, useShellOption, disableConsoleOutputs: !dataHandler, }).then((_val: number) => { // nothing, returns void }); } /** * Execute a shell command. * * This function is a promisified version of Node's `spawn()` * with extra options added * ( https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options ). * * @param bin The executable program to call. * * @param args The arguments for the program. * * @param options.successExitCodes The acceptable codes the * process must exit with to be considered as a success. * Defaults to [0]. * * @param options.outputHandler A function that will receive * the output of the process (stdOut and stdErr). * This allows you to capture this output and manipulate it. * No handler by default. * * @param options.disableConsoleOutputs Set to `true` in order * to disable outputs in the current parent process * (you can still capture them using a `options.dataHandler`). * Defaults to `false`. * * @param options.stdio See https://nodejs.org/api/child_process.html#child_process_options_stdio * Defaults to `['inherit', 'pipe', 'pipe']`. * * @param options.useShellOption See the "shell" option: * https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options * Defaults to `true`. * * @returns The exit code * * @throws Will fail with a `ExecError` error if the process returns * a code different than `options.successExitCodes` ("0" by default). * The exit code would then be available in the generated Error: * `err.exitCode`. */ public async exec( bin: string, args: string[] = [], options?: { successExitCodes?: number | number[]; outputHandler?: (stdoutOutput: string, stderrOutput: string) => void; disableConsoleOutputs?: boolean; stdio?: StdioOptions; useShellOption?: boolean; }, ): Promise<number> { const optionsClean = options ?? {}; optionsClean.useShellOption = optionsClean.useShellOption ?? true; optionsClean.successExitCodes = optionsClean.successExitCodes ? isArray(optionsClean.successExitCodes) ? optionsClean.successExitCodes : [optionsClean.successExitCodes] : [0]; optionsClean.stdio = optionsClean.stdio ?? ['inherit', 'pipe', 'pipe']; optionsClean.disableConsoleOutputs = optionsClean.disableConsoleOutputs ?? false; if (this.isBlank(bin)) { throw new ExecError(`The "bin" argument is required`, 1); } return new Promise<number>((resolve, reject) => { const spawnedProcess = spawn(bin, args, { detached: false, stdio: optionsClean.stdio, shell: optionsClean.useShellOption, windowsVerbatimArguments: false, }); spawnedProcess.on('close', (code: number) => { const successExitCodes = optionsClean.successExitCodes as number[]; if (!successExitCodes.includes(code)) { reject( new ExecError( `Expected success codes were "${successExitCodes.toString()}", but the process exited with "${code}".`, code, ), ); } else { resolve(code); } }); spawnedProcess.stdout.on('data', (output: string) => { const outputClean = output ? output.toString() : ''; if (optionsClean.outputHandler) { optionsClean.outputHandler(outputClean, null); } if (!optionsClean.disableConsoleOutputs) { process.stdout.write(outputClean); } }); spawnedProcess.stderr.on('data', (output: string) => { const outputClean = output ? output.toString() : ''; if (optionsClean.outputHandler) { optionsClean.outputHandler(null, outputClean); } if (!optionsClean.disableConsoleOutputs) { process.stderr.write(outputClean); } }); }); } } /** * Error thrown when a process launched with `exec()` fails. */ // tslint:disable-next-line: max-classes-per-file export class ExecError extends Error { constructor( message: string, public exitCode: number, ) { super(message); } } export function getValueDescription(value: any): string { return ${JSON.stringify(value)} »`; } export function getValueDescriptionWithType(value: any): string { const valueType = isObject(value) ? value.constructor.name : typeof value; return getValueDescription(value) + ` (${valueType})`; } export const utils: Utils = new Utils();