UNPKG

@villedemontreal/general-utils

Version:
511 lines 18.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.utils = exports.ExecError = exports.Utils = void 0; exports.getValueDescription = getValueDescription; exports.getValueDescriptionWithType = getValueDescriptionWithType; const child_process_1 = require("child_process"); const fs = require("fs"); const getPort = require("get-port"); const pathUtils = require("path"); const rimraf_1 = require("rimraf"); const tsconfig = require("tsconfig-extends"); const constants_1 = require("./config/constants"); const lodash_1 = require("lodash"); /** * General utilities */ class Utils { constructor() { /** * Creates a "range", an array of continuous integers, from * "start" to "end". * Both "start" and "end" are inclusive. */ this.range = (start, end) => { return [...Array(1 + end - start).keys()].map((v) => start + v); }; /** * 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)"! */ this.dateTransformer = (value) => { let date; if ((0, lodash_1.isNil)(value)) { return null; } if ((0, lodash_1.isDate)(value)) { date = value; } else if (!(0, lodash_1.isString)(value) || exports.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). */ this.throwNotManaged = (messagePrefix, element) => { 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. */ this.isObjectStrict = (val) => { if (!val) { return false; } return (0, lodash_1.isObject)(val) && !(0, lodash_1.isArray)(val) && !(0, lodash_1.isDate)(val) && !(0, lodash_1.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". */ this.arrayContainsObjectWithKeyEqualsTo = (array, key, value) => { if (!array || !(0, lodash_1.isArray)(array) || array.length < 1) { return false; } for (const obj of array) { if (this.isObjectStrict(obj) && (0, lodash_1.isEqual)(obj[key], value)) { return true; } } return false; }; } /** * Promisified setTimeout() utility function. * * @param ms The number of milliseconds to sleep for. */ async sleep(ms) { await new Promise((resolve, reject) => { setTimeout(resolve, ms); }); } /** * Checks if a String is null, undefined, * empty, or contains only whitespaces. */ isBlank(str) { 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. */ isNaNSafe(value) { 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. */ getDefinedOrNull(el) { 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. */ isIntegerValue(value, positiveOnly = false, includeZero = true) { 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. */ stringToBoolean(str) { 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 */ isSafeToDelete(path) { if (!path) { return false; } let pathClean = path; pathClean = pathUtils.normalize(pathClean); pathClean = pathClean.replace(/\\/g, '/'); pathClean = (0, lodash_1.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). */ isDir(dirPath) { 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. */ isDirEmpty(dirPath) { 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. */ deleteFile(filePath) { if (!this.isSafeToDelete(filePath)) { throw new Error("Unsafe file to delete. A file to delete can't be at the root."); } return (0, rimraf_1.rimraf)(filePath); } /** * Deletes a directory, promisified and in a * solid way. * * You can't delete a root directory using this function. */ async deleteDir(dirPath) { if (!this.isSafeToDelete(dirPath)) { throw new Error("Unsafe dir to delete. A dir to delete can't be at the root."); } try { return (0, rimraf_1.rimraf)(dirPath); } catch (err) { // ========================================== // Try recursively as rimraf may sometimes // fail in infrequent situations... // ========================================== await this.clearDir(dirPath); return (0, rimraf_1.rimraf)(dirPath); } } /** * Clears a directory, promisified and in a * solid way. * * You can't clear a root directory using this function. */ async clearDir(dirPath) { 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((resolve, reject) => { fs.readdir(dirPath, async (err, paths) => { 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(); }); }); } get tscCompilerOptions() { if (!this.tscCompilerOptionsParams) { this.tscCompilerOptionsParams = []; const compilerOptions = tsconfig.load_file_sync(constants_1.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 */ async tsc(files) { if (!files) { return; } const cmd = 'node'; const tscCmd = constants_1.constants.findModulePath('node_modules/typescript/lib/tsc.js'); const args = [tscCmd].concat(this.tscCompilerOptions).concat(files); await this.execPromisified(cmd, args); } /** * Returns a free port. */ async findFreePort() { 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. */ isValidDate(date) { if (this.isBlank(date)) { return false; } return Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date.getTime()); } /** * @deprecated Use `exec()` instead. */ execPromisified(command, args, dataHandler = null, useShellOption = false) { return this.exec(command, args, { outputHandler: dataHandler, useShellOption, disableConsoleOutputs: !dataHandler, }).then((_val) => { // 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`. */ async exec(bin, args = [], options) { const optionsClean = options ?? {}; optionsClean.useShellOption = optionsClean.useShellOption ?? true; optionsClean.successExitCodes = optionsClean.successExitCodes ? (0, lodash_1.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((resolve, reject) => { const spawnedProcess = (0, child_process_1.spawn)(bin, args, { detached: false, stdio: optionsClean.stdio, shell: optionsClean.useShellOption, windowsVerbatimArguments: false, }); spawnedProcess.on('close', (code) => { const successExitCodes = optionsClean.successExitCodes; 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) => { const outputClean = output ? output.toString() : ''; if (optionsClean.outputHandler) { optionsClean.outputHandler(outputClean, null); } if (!optionsClean.disableConsoleOutputs) { process.stdout.write(outputClean); } }); spawnedProcess.stderr.on('data', (output) => { const outputClean = output ? output.toString() : ''; if (optionsClean.outputHandler) { optionsClean.outputHandler(null, outputClean); } if (!optionsClean.disableConsoleOutputs) { process.stderr.write(outputClean); } }); }); } } exports.Utils = Utils; /** * Error thrown when a process launched with `exec()` fails. */ // tslint:disable-next-line: max-classes-per-file class ExecError extends Error { constructor(message, exitCode) { super(message); this.exitCode = exitCode; } } exports.ExecError = ExecError; function getValueDescription(value) { return ${JSON.stringify(value)} »`; } function getValueDescriptionWithType(value) { const valueType = (0, lodash_1.isObject)(value) ? value.constructor.name : typeof value; return getValueDescription(value) + ` (${valueType})`; } exports.utils = new Utils(); //# sourceMappingURL=utils.js.map