UNPKG

@villedemontreal/general-utils

Version:
561 lines 20.8 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 path = require("path"); const get_port_1 = require("get-port"); const lodash_1 = require("lodash"); const pathUtils = require("path"); const tsconfig = require("tsconfig-extends"); const constants_1 = require("./config/constants"); /** * 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) => { 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.... // eslint-disable-next-line @/prefer-template 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') { // eslint-disable-next-line @/prefer-template 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. */ async deleteFile(filePath) { if (!this.isSafeToDelete(filePath)) { throw new Error("Unsafe file to delete. A file to delete can't be at the root."); } await fs.promises.rm(filePath, { force: true }); } /** * 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 { await fs.promises.rm(dirPath, { recursive: true, force: true }); } catch { // ========================================== // Try recursively as fs.promises.rm may sometimes // fail in infrequent situations... // ========================================== await this.clearDir(dirPath); await fs.promises.rm(dirPath, { recursive: true, force: true }); } } /** * 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! const paths = await fs.promises.readdir(dirPath); 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); } } } get tscCompilerOptions() { if (!this.tscCompilerOptionsParams) { this.tscCompilerOptionsParams = []; const compilerOptions = tsconfig.load_file_sync(path.join(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, ...this.tscCompilerOptions, ...files]; await this.exec(cmd, args, { useShellOption: false }); } /** * Returns a free port. */ async findFreePort() { return await (0, get_port_1.default)(); } /** * 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()); } shellescape(args) { const isWindows = process.platform === 'win32'; if (isWindows) { return this.shellescapeForWindowsCmd(args); } else { return this.shellescapeForLinuxShell(args); } } shellescapeForLinuxShell(args) { return args.map((x) => this.shellescapeArgumentForLinuxShell(x)).join(' '); } shellescapeForWindowsCmd(args) { return args.map((x) => this.shellescapeArgumentForWindowsCmd(x)).join(' '); } shellescapeArgumentForLinuxShell(a) { // Function inspired from: https://github.com/xxorax/node-shell-escape/blob/master/shell-escape.js if (/[^A-Za-z0-9_/.:=-]/.test(a)) { a = a.replace(/\\\\/g, '\\'); a = a.replace(/\\/g, '\\\\'); a = `'${a.replace(/'/g, "'\\''")}'`; a = a .replace(/^(?:'')+/g, '') // unduplicate single-quote at the beginning .replace(/\\'''/g, "\\'") // remove non-escaped single-quote if there are enclosed between 2 escaped .replace('\n', '\\n') // handle new lines .replace('\t', '\\t'); // handle tabs } return a; } shellescapeArgumentForWindowsCmd(a) { if (/[^A-Za-z0-9_/\\.$:=-]/.test(a)) { a = `"${a.replace(/"/g, '""')}"`; a = a .replace('\n', '\\n') // handle new lines .replace('\t', '\\t'); // handle tabs } return a; } /** * @deprecated Use `exec()` instead. */ execPromisified(command, args, dataHandler = null, useShellOption = false) { return this.exec(command, args, { outputHandler: dataHandler, useShellOption, disableConsoleOutputs: !dataHandler, }).then(() => { // 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`. * * @param options.escapeArgs will automatically escape the submitted args. * Defaults to `false` to avoid any breaking changes. * * @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.escapeArgs = optionsClean.escapeArgs ?? false; 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) => { let spawnedProcess; if (optionsClean.useShellOption && optionsClean.escapeArgs) { const cmd = this.shellescape([bin, ...args]); spawnedProcess = (0, child_process_1.spawn)(cmd, { detached: false, stdio: optionsClean.stdio, shell: optionsClean.useShellOption, windowsVerbatimArguments: false, }); } else { spawnedProcess = (0, child_process_1.spawn)(bin, args, { detached: false, stdio: optionsClean.stdio, shell: optionsClean.useShellOption, windowsVerbatimArguments: false, }); } spawnedProcess.on('error', (err) => { reject(new ExecError(`Error while executing command: ${err.message}`, 1)); }); 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. */ 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