UNPKG

@decaf-ts/utils

Version:

module management utils for decaf-ts

1,276 lines (1,269 loc) 477 kB
import prompts from 'prompts'; import { parseArgs } from 'util'; import { Logging, patchString, LoggedClass, LoggedEnvironment } from '@decaf-ts/logging'; import fs from 'fs'; import path from 'path'; import { spawn } from 'child_process'; import { style } from 'styled-string-builder'; import https from 'https'; import { rollup } from 'rollup'; import typescript from '@rollup/plugin-typescript'; import commonjs from '@rollup/plugin-commonjs'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import json from '@rollup/plugin-json'; import * as ts from 'typescript'; import { ModuleKind } from 'typescript'; /** * @description Represents a user input prompt with various configuration options. * @summary This class provides a flexible interface for creating and managing user input prompts. * It implements the PromptObject interface from the 'prompts' library and offers methods to set * various properties of the prompt. The class also includes static methods for common input scenarios * and argument parsing. * * @template R - The type of the prompt name, extending string. * * @param name - The name of the prompt, used as the key in the returned answers object. * * @class * @example * ```typescript * import { UserInput } from '@decaf-ts/utils'; * * // Create a simple text input * const nameInput = new UserInput('name') * .setMessage('What is your name?') * .setInitial('User'); * * // Create a number input with validation * const ageInput = new UserInput('age') * .setType('number') * .setMessage('How old are you?') * .setMin(0) * .setMax(120); * * // Ask for input and process the results * async function getUserInfo() { * const answers = await UserInput.ask([nameInput, ageInput]); * console.log(`Hello ${answers.name}, you are ${answers.age} years old.`); * } * * getUserInfo(); * ``` * * @mermaid * sequenceDiagram * participant Client * participant UserInput * participant PromptLibrary * * Client->>UserInput: new UserInput(name) * Client->>UserInput: setMessage(message) * Client->>UserInput: setType(type) * Client->>UserInput: setInitial(initial) * Client->>UserInput: Other configuration methods * * Client->>UserInput: ask() * UserInput->>PromptLibrary: prompts(question) * PromptLibrary->>Client: Display prompt * Client->>PromptLibrary: User provides input * PromptLibrary->>UserInput: Return answers * UserInput->>Client: Return processed answers */ class UserInput { static { this.logger = Logging.for(UserInput); } constructor(name) { /** * @description The type of the prompt. * @summary Determines the input method (e.g., text, number, confirm). */ this.type = "text"; this.name = name; } /** * @description Sets the type of the prompt. * @summary Configures the input method for the prompt. * * @param type - The type of the prompt. * @returns This UserInput instance for method chaining. */ setType(type) { UserInput.logger.verbose(`Setting type to: ${type}`); this.type = type; return this; } /** * @description Sets the message of the prompt. * @summary Configures the question or instruction presented to the user. * * @param value - The message to be displayed. * @returns This UserInput instance for method chaining. */ setMessage(value) { UserInput.logger.verbose(`Setting message to: ${value}`); this.message = value; return this; } /** * @description Sets the initial value of the prompt. * @summary Configures the default value presented to the user. * * @param value - The initial value. * @returns This UserInput instance for method chaining. */ setInitial(value) { UserInput.logger.verbose(`Setting initial value to: ${value}`); this.initial = value; return this; } /** * @description Sets the style of the prompt. * @summary Configures the visual style of the prompt. * * @param value - The style to be applied. * @returns This UserInput instance for method chaining. */ setStyle(value) { UserInput.logger.verbose(`Setting style to: ${value}`); this.style = value; return this; } /** * @description Sets the format function of the prompt. * @summary Configures a function to format the user's input before it's returned. * * @param value - The format function. * @returns This UserInput instance for method chaining. */ setFormat(value) { UserInput.logger.verbose(`Setting format function`); this.format = value; return this; } /** * @description Sets the validation function of the prompt. * @summary Configures a function to validate the user's input. * * @param value - The validation function. * @returns This UserInput instance for method chaining. */ setValidate(value) { UserInput.logger.verbose(`Setting validate function`); this.validate = value; return this; } /** * @description Sets the onState callback of the prompt. * @summary Configures a function to be called when the state of the prompt changes. * * @param value - The onState callback function. * @returns This UserInput instance for method chaining. */ setOnState(value) { UserInput.logger.verbose(`Setting onState callback`); this.onState = value; return this; } /** * @description Sets the minimum value for number inputs. * @summary Configures the lowest number the user can input. * * @param value - The minimum value. * @returns This UserInput instance for method chaining. */ setMin(value) { UserInput.logger.verbose(`Setting min value to: ${value}`); this.min = value; return this; } /** * @description Sets the maximum value for number inputs. * @summary Configures the highest number the user can input. * * @param value - The maximum value. * @returns This UserInput instance for method chaining. */ setMax(value) { UserInput.logger.verbose(`Setting max value to: ${value}`); this.max = value; return this; } /** * @description Sets whether to allow float values for number inputs. * @summary Configures whether decimal numbers are allowed. * * @param value - Whether to allow float values. * @returns This UserInput instance for method chaining. */ setFloat(value) { UserInput.logger.verbose(`Setting float to: ${value}`); this.float = value; return this; } /** * @description Sets the number of decimal places to round to for float inputs. * @summary Configures the precision of float inputs. * * @param value - The number of decimal places. * @returns This UserInput instance for method chaining. */ setRound(value) { UserInput.logger.verbose(`Setting round to: ${value}`); this.round = value; return this; } /** * @description Sets the instructions for the user. * @summary Configures additional guidance provided to the user. * * @param value - The instructions. * @returns This UserInput instance for method chaining. */ setInstructions(value) { UserInput.logger.verbose(`Setting instructions to: ${value}`); this.instructions = value; return this; } /** * @description Sets the increment value for number inputs. * @summary Configures the step size when increasing or decreasing the number. * * @param value - The increment value. * @returns This UserInput instance for method chaining. */ setIncrement(value) { UserInput.logger.verbose(`Setting increment to: ${value}`); this.increment = value; return this; } /** * @description Sets the separator for list inputs. * @summary Configures the character used to separate list items. * * @param value - The separator character. * @returns This UserInput instance for method chaining. */ setSeparator(value) { UserInput.logger.verbose(`Setting separator to: ${value}`); this.separator = value; return this; } /** * @description Sets the active option style for select inputs. * @summary Configures the style applied to the currently selected option. * * @param value - The active option style. * @returns This UserInput instance for method chaining. */ setActive(value) { UserInput.logger.verbose(`Setting active style to: ${value}`); this.active = value; return this; } /** * @description Sets the inactive option style for select inputs. * @summary Configures the style applied to non-selected options. * * @param value - The inactive option style. * @returns This UserInput instance for method chaining. */ setInactive(value) { UserInput.logger.verbose(`Setting inactive style to: ${value}`); this.inactive = value; return this; } /** * @description Sets the choices for select, multiselect, or autocomplete inputs. * @summary Configures the available options that the user can select from in choice-based prompts. * * @param value - The array of choices or a function to determine the choices. * @returns This UserInput instance for method chaining. */ setChoices(value) { UserInput.logger.verbose(`Setting choices: ${JSON.stringify(value)}`); this.choices = value; return this; } /** * @description Sets the hint text for the prompt. * @summary Configures additional information displayed to the user. * * @param value - The hint text. * @returns This UserInput instance for method chaining. */ setHint(value) { UserInput.logger.verbose(`Setting hint to: ${value}`); this.hint = value; return this; } /** * @description Sets the warning text for the prompt. * @summary Configures a warning message displayed to the user. * * @param value - The warning text. * @returns This UserInput instance for method chaining. */ setWarn(value) { UserInput.logger.verbose(`Setting warn to: ${value}`); this.warn = value; return this; } /** * @description Sets the suggestion function for autocomplete inputs. * @summary Configures a function that provides suggestions based on the user's input and available choices. * * @param value - A function that takes the current input and available choices and returns a Promise resolving to suggestions. * @returns This UserInput instance for method chaining. */ setSuggest(value) { UserInput.logger.verbose(`Setting suggest function`); this.suggest = value; return this; } /** * @description Sets the limit for list inputs. * @summary Configures the maximum number of items that can be selected in list-type prompts. * @template R - The type of the prompt name, extending string. * @param value - The maximum number of items that can be selected, or a function to determine this value. * @return This UserInput instance for method chaining. */ setLimit(value) { UserInput.logger.verbose(`Setting limit to: ${value}`); this.limit = value; return this; } /** * @description Sets the mask for password inputs. * @summary Configures the character used to hide the user's input in password-type prompts. * @template R - The type of the prompt name, extending string. * @param value - The character used to mask the input, or a function to determine this value. * @return This UserInput instance for method chaining. */ setMask(value) { UserInput.logger.verbose(`Setting mask to: ${value}`); this.mask = value; return this; } /** * @description Sets the stdout stream for the prompt. * @summary Configures the output stream used by the prompt for displaying messages and results. * @param value - The Writable stream to be used as stdout. * @return This UserInput instance for method chaining. */ setStdout(value) { UserInput.logger.verbose(`Setting stdout stream`); this.stdout = value; return this; } /** * @description Sets the stdin stream for the prompt. * @summary Configures the input stream used by the prompt for receiving user input. * @param value - The Readable stream to be used as stdin. * @return This UserInput instance for method chaining. */ setStdin(value) { this.stdin = value; return this; } /** * @description Asks the user for input based on the current UserInput configuration. * @summary Prompts the user and returns their response as a single value. * @template R - The type of the prompt name, extending string. * @return A Promise that resolves to the user's answer. */ async ask() { return (await UserInput.ask(this))[this.name]; } /** * @description Asks the user one or more questions based on the provided UserInput configurations. * @summary Prompts the user with one or more questions and returns their answers as an object. * @template R - The type of the prompt name, extending string. * @param question - A single UserInput instance or an array of UserInput instances. * @return A Promise that resolves to an object containing the user's answers. * @mermaid * sequenceDiagram * participant U as User * participant A as ask method * participant P as prompts library * A->>P: Call prompts with question(s) * P->>U: Display prompt(s) * U->>P: Provide input * P->>A: Return answers * A->>A: Process answers * A-->>Caller: Return processed answers */ static async ask(question) { const log = UserInput.logger.for(this.ask); if (!Array.isArray(question)) { question = [question]; } let answers; try { log.verbose(`Asking questions: ${question.map((q) => q.name).join(", ")}`); answers = await prompts(question); log.verbose(`Received answers: ${JSON.stringify(answers, null, 2)}`); } catch (error) { throw new Error(`Error while getting input: ${error}`); } return answers; } /** * @description Asks the user for a number input. * @summary Prompts the user to enter a number, with optional minimum, maximum, and initial values. * @param name - The name of the prompt, used as the key in the returned answers object. * @param question - The message displayed to the user. * @param min - The minimum allowed value (optional). * @param max - The maximum allowed value (optional). * @param initial - The initial value presented to the user (optional). * @return A Promise that resolves to the number entered by the user. */ static async askNumber(name, question, min, max, initial) { const log = UserInput.logger.for(this.askNumber); log.verbose(`Asking number input: undefined, question: ${question}, min: ${min}, max: ${max}, initial: ${initial}`); const userInput = new UserInput(name) .setMessage(question) .setType("number"); if (typeof min === "number") userInput.setMin(min); if (typeof max === "number") userInput.setMax(max); if (typeof initial === "number") userInput.setInitial(initial); return (await this.ask(userInput))[name]; } /** * @description Asks the user for a text input. * @summary Prompts the user to enter text, with optional masking and initial value. * @param name - The name of the prompt, used as the key in the returned answers object. * @param question - The message displayed to the user. * @param mask - The character used to mask the input (optional, for password-like inputs). * @param initial - The initial value presented to the user (optional). * @return A Promise that resolves to the text entered by the user. */ static async askText(name, question, mask = undefined, initial) { const log = UserInput.logger.for(this.askText); log.verbose(`Asking text input: undefined, question: ${question}, mask: ${mask}, initial: ${initial}`); const userInput = new UserInput(name).setMessage(question); if (mask) userInput.setMask(mask); if (typeof initial === "string") userInput.setInitial(initial); return (await this.ask(userInput))[name]; } /** * @description Asks the user for a confirmation (yes/no). * @summary Prompts the user with a yes/no question and returns a boolean result. * @param name - The name of the prompt, used as the key in the returned answers object. * @param question - The message displayed to the user. * @param initial - The initial value presented to the user (optional). * @return A Promise that resolves to a boolean representing the user's answer. */ static async askConfirmation(name, question, initial) { const log = UserInput.logger.for(this.askConfirmation); log.verbose(`Asking confirmation input: undefined, question: ${question}, initial: ${initial}`); const userInput = new UserInput(name) .setMessage(question) .setType("confirm"); if (typeof initial !== "undefined") userInput.setInitial(initial); return (await this.ask(userInput))[name]; } /** * @description Repeatedly asks for input until a valid response is given or the limit is reached. * @summary This method insists on getting a valid input from the user, allowing for a specified number of attempts. * * @template R - The type of the expected result. * @param input - The UserInput instance to use for prompting. * @param {function(string):boolean} test - Validator function receiving the user input and returning whether it is valid. * @param defaultConfirmation - The default value for the confirmation prompt (true for yes, false for no). * @param limit - The maximum number of attempts allowed (default is 1). * @return A Promise that resolves to the valid input or undefined if the limit is reached. * * @mermaid * sequenceDiagram * participant U as User * participant I as insist method * participant A as ask method * participant T as test function * participant C as askConfirmation method * loop Until valid input or limit reached * I->>A: Call ask with input * A->>U: Prompt user * U->>A: Provide input * A->>I: Return result * I->>T: Test result * alt Test passes * I->>C: Ask for confirmation * C->>U: Confirm input * U->>C: Provide confirmation * C->>I: Return confirmation * alt Confirmed * I-->>Caller: Return valid result * else Not confirmed * I->>I: Continue loop * end * else Test fails * I->>I: Continue loop * end * end * I-->>Caller: Return undefined if limit reached */ static async insist(input, test, defaultConfirmation, limit = 1) { const log = UserInput.logger.for(this.insist); log.verbose(`Insisting on input: ${input.name}, test: ${test.toString()}, defaultConfirmation: ${defaultConfirmation}, limit: ${limit}`); let result = undefined; let count = 0; let confirmation; try { do { result = (await UserInput.ask(input))[input.name]; if (!test(result)) { result = undefined; continue; } confirmation = await UserInput.askConfirmation(`${input.name}-confirm`, `Is the ${input.type} correct?`, defaultConfirmation); if (!confirmation) result = undefined; } while (typeof result === "undefined" && limit > 1 && count++ < limit); } catch (e) { log.error(`Error while insisting: ${e}`); throw e; } if (typeof result === "undefined") log.info("no selection..."); return result; } /** * @description Repeatedly asks for text input until a valid response is given or the limit is reached. * @summary This method insists on getting a valid text input from the user, allowing for a specified number of attempts. * * @param name - The name of the prompt, used as the key in the returned answers object. * @param question - The message displayed to the user. * @param {function(number):boolean} test - Validator function receiving the user input and returning whether it is valid. * @param mask - The character used to mask the input (optional, for password-like inputs). * @param initial - The initial value presented to the user (optional). * @param defaultConfirmation - The default value for the confirmation prompt (true for yes, false for no). * @param limit - The maximum number of attempts allowed (default is -1, meaning unlimited). * @return A Promise that resolves to the valid input or undefined if the limit is reached. */ static async insistForText(name, question, test, mask = undefined, initial, defaultConfirmation = false, limit = -1) { const log = UserInput.logger.for(this.insistForText); log.verbose(`Insisting for text input: undefined, question: ${question}, test: ${test.toString()}, mask: ${mask}, initial: ${initial}, defaultConfirmation: ${defaultConfirmation}, limit: ${limit}`); const userInput = new UserInput(name).setMessage(question); if (mask) userInput.setMask(mask); if (typeof initial === "string") userInput.setInitial(initial); return (await this.insist(userInput, test, defaultConfirmation, limit)); } /** * @description Repeatedly asks for number input until a valid response is given or the limit is reached. * @summary This method insists on getting a valid number input from the user, allowing for a specified number of attempts. * * @param name - The name of the prompt, used as the key in the returned answers object. * @param question - The message displayed to the user. * @param test - A function to validate the user's input. * @param min - The minimum allowed value (optional). * @param max - The maximum allowed value (optional). * @param initial - The initial value presented to the user (optional). * @param defaultConfirmation - The default value for the confirmation prompt (true for yes, false for no). * @param limit - The maximum number of attempts allowed (default is -1, meaning unlimited). * @return A Promise that resolves to the valid input or undefined if the limit is reached. */ static async insistForNumber(name, question, test, min, max, initial, defaultConfirmation = false, limit = -1) { const log = UserInput.logger.for(this.insistForNumber); log.verbose(`Insisting for number input: undefined, question: ${question}, test: ${test.toString()}, min: ${min}, max: ${max}, initial: ${initial}, defaultConfirmation: ${defaultConfirmation}, limit: ${limit}`); const userInput = new UserInput(name) .setMessage(question) .setType("number"); if (typeof min === "number") userInput.setMin(min); if (typeof max === "number") userInput.setMax(max); if (typeof initial === "number") userInput.setInitial(initial); return (await this.insist(userInput, test, defaultConfirmation, limit)); } /** * @description Parses command-line arguments based on the provided options. * @summary Uses Node.js's util.parseArgs to parse command-line arguments and return the result. * @param options - Configuration options for parsing arguments. * @return An object containing the parsed arguments. * @mermaid * sequenceDiagram * participant C as Caller * participant P as parseArgs method * participant U as util.parseArgs * C->>P: Call with options * P->>P: Prepare args object * P->>U: Call parseArgs with prepared args * U->>P: Return parsed result * P-->>C: Return ParseArgsResult */ static parseArgs(options) { const log = UserInput.logger.for(this.parseArgs); const args = { args: process.argv.slice(2), options: options, }; log.debug(`Parsing arguments: ${JSON.stringify(args, null, 2)}`); try { return parseArgs(args); } catch (error) { log.debug(`Error while parsing arguments:\n${JSON.stringify(args, null, 2)}\n | options\n${JSON.stringify(options, null, 2)}\n | ${error}`); throw new Error(`Error while parsing arguments: ${error}`); } } } /** * @description Default command options for CLI commands. * @summary Defines the structure and default values for common command-line options used across various CLI commands. * @const DefaultCommandOptions * @typedef {Object} DefaultCommandOptions * @property {Object} verbose - Verbosity level option. * @property {string} verbose.type - The type of the verbose option (number). * @property {string} verbose.short - The short flag for the verbose option (V). * @property {number} verbose.default - The default value for verbosity (0). * @property {Object} version - Version display option. * @property {string} version.type - The type of the version option (boolean). * @property {string} version.short - The short flag for the version option (v). * @property {undefined} version.default - The default value for version display (undefined). * @property {Object} help - Help display option. * @property {string} help.type - The type of the help option (boolean). * @property {string} help.short - The short flag for the help option (h). * @property {boolean} help.default - The default value for help display (false). * @property {Object} logLevel - Log level option. * @property {string} logLevel.type - The type of the logLevel option (string). * @property {string} logLevel.default - The default value for log level ("info"). * @property {Object} logStyle - Log styling option. * @property {string} logStyle.type - The type of the logStyle option (boolean). * @property {boolean} logStyle.default - The default value for log styling (true). * @property {Object} timestamp - Timestamp display option. * @property {string} timestamp.type - The type of the timestamp option (boolean). * @property {boolean} timestamp.default - The default value for timestamp display (true). * @property {Object} banner - Banner display option. * @property {string} banner.type - The type of the banner option (boolean). * @property {boolean} banner.default - The default value for banner display (false). * @memberOf module:utils */ const DefaultCommandOptions = { verbose: { type: "boolean", short: "V", default: undefined, }, version: { type: "boolean", short: "v", default: undefined, }, help: { type: "boolean", short: "h", default: false, }, logLevel: { type: "string", default: "info", }, logStyle: { type: "boolean", default: true, }, timestamp: { type: "boolean", default: true, }, banner: { type: "boolean", default: true, }, }; /** * @description Default command values derived from DefaultCommandOptions. * @summary Creates an object with the default values of all options defined in DefaultCommandOptions. * @const DefaultCommandValues * @typedef {Object} DefaultCommandValues * @property {unknown} [key: string] - The default value for each option in DefaultCommandOptions. * @memberOf module:utils */ const DefaultCommandValues = Object.keys(DefaultCommandOptions).reduce((acc, key) => { acc[key] = DefaultCommandOptions[key].default; return acc; }, {}); /** * @description Default encoding for text operations. * @summary The standard UTF-8 encoding used for text processing. * @const {string} Encoding * @memberOf module:utils */ const Encoding = "utf-8"; /** * @description Regular expression for semantic versioning. * @summary A regex pattern to match and parse semantic version strings. * @const {RegExp} SemVersionRegex * @memberOf module:utils */ const SemVersionRegex = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z])))/g; /** * @description Enum for semantic version components. * @summary Defines the three levels of semantic versioning: PATCH, MINOR, and MAJOR. * @enum {string} * @memberOf module:utils */ var SemVersion; (function (SemVersion) { /** Patch version for backwards-compatible bug fixes. */ SemVersion["PATCH"] = "patch"; /** Minor version for backwards-compatible new features. */ SemVersion["MINOR"] = "minor"; /** Major version for changes that break backwards compatibility. */ SemVersion["MAJOR"] = "major"; })(SemVersion || (SemVersion = {})); /** * @description Flag to indicate non-CI environment. * @summary Used to specify that a command should run outside of a Continuous Integration environment. * @const {string} NoCIFLag * @memberOf module:utils */ const NoCIFLag = "-no-ci"; /** * @description Key for the setup script in package.json. * @summary Identifies the script that runs after package installation. * @const {string} SetupScriptKey * @memberOf module:utils */ const SetupScriptKey = "postinstall"; /** * @description Enum for various authentication tokens. * @summary Defines the file names for storing different types of authentication tokens. * @enum {string} * @memberOf module:utils */ var Tokens; (function (Tokens) { /** Git authentication token file name. */ Tokens["GIT"] = ".token"; /** NPM authentication token file name. */ Tokens["NPM"] = ".npmtoken"; /** Docker authentication token file name. */ Tokens["DOCKER"] = ".dockertoken"; /** Confluence authentication token file name. */ Tokens["CONFLUENCE"] = ".confluence-token"; })(Tokens || (Tokens = {})); /** * @description Code used to indicate an operation was aborted. * @summary Standard message used when a process is manually terminated. * @const {string} AbortCode * @memberOf module:utils */ const AbortCode = "Aborted"; /** * @description A standard output writer for handling command execution output. * @summary This class implements the OutputWriter interface and provides methods for * handling various types of output from command execution, including standard output, * error output, and exit codes. It also includes utility methods for parsing commands * and resolving or rejecting promises based on execution results. * * @template R - The type of the resolved value, defaulting to string. * * @param cmd - The command string to be executed. * @param lock - A PromiseExecutor to control the asynchronous flow. * @param args - Additional arguments (unused in the current implementation). * * @class * @example * ```typescript * import { StandardOutputWriter } from '@decaf-ts/utils'; * import { PromiseExecutor } from '@decaf-ts/utils'; * * // Create a promise executor * const executor: PromiseExecutor<string> = { * resolve: (value) => console.log(`Resolved: ${value}`), * reject: (error) => console.error(`Rejected: ${error.message}`) * }; * * // Create a standard output writer * const writer = new StandardOutputWriter('ls -la', executor); * * // Use the writer to handle command output * writer.data('File list output...'); * writer.exit(0, ['Command executed successfully']); * ``` * * @mermaid * sequenceDiagram * participant Client * participant StandardOutputWriter * participant Logger * participant PromiseExecutor * * Client->>StandardOutputWriter: new StandardOutputWriter(cmd, lock) * StandardOutputWriter->>Logger: Logging.for(cmd) * * Client->>StandardOutputWriter: data(chunk) * StandardOutputWriter->>StandardOutputWriter: log("stdout", chunk) * StandardOutputWriter->>Logger: logger.info(log) * * Client->>StandardOutputWriter: error(chunk) * StandardOutputWriter->>StandardOutputWriter: log("stderr", chunk) * StandardOutputWriter->>Logger: logger.info(log) * * Client->>StandardOutputWriter: exit(code, logs) * StandardOutputWriter->>StandardOutputWriter: log("stdout", exitMessage) * alt code === 0 * StandardOutputWriter->>StandardOutputWriter: resolve(logs) * StandardOutputWriter->>PromiseExecutor: lock.resolve(reason) * else code !== 0 * StandardOutputWriter->>StandardOutputWriter: reject(error) * StandardOutputWriter->>PromiseExecutor: lock.reject(reason) * end */ class StandardOutputWriter { constructor(cmd, lock, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { this.cmd = cmd; this.lock = lock; this.logger = Logging.for(this.cmd); } /** * @description Logs output to the console. * @summary Formats and logs the given data with a timestamp and type indicator. * * @param type - The type of output (stdout or stderr). * @param data - The data to be logged. */ log(type, data) { data = Buffer.isBuffer(data) ? data.toString(Encoding) : data; const log = type === "stderr" ? style(data).red.text : data; this.logger.info(log); } /** * @description Handles standard output data. * @summary Logs the given chunk as standard output. * * @param chunk - The data chunk to be logged. */ data(chunk) { this.log("stdout", String(chunk)); } /** * @description Handles error output data. * @summary Logs the given chunk as error output. * * @param chunk - The error data chunk to be logged. */ error(chunk) { this.log("stderr", String(chunk)); } /** * @description Handles error objects. * @summary Logs the error message from the given Error object. * * @param err - The Error object to be logged. */ errors(err) { this.log("stderr", `Error executing command exited : ${err}`); } /** * @description Handles the exit of a command. * @summary Logs the exit code and resolves or rejects the promise based on the code. * * @param code - The exit code of the command. * @param logs - Array of log messages to be processed before exiting. */ exit(code, logs) { this.log("stdout", `command exited code : ${code === 0 ? style(code.toString()).green.text : style(code === null ? "null" : code.toString()).red.text}`); if (code === 0) { this.resolve(logs.map((l) => l.trim()).join("\n")); } else { this.reject(new Error(logs.length ? logs.join("\n") : code.toString())); } } /** * @description Parses a command string or array into components. * @summary Converts the command into a consistent format and stores it, then returns it split into command and arguments. * * @param command - The command as a string or array of strings. * @return A tuple containing the command and its arguments as separate elements. */ parseCommand(command) { command = typeof command === "string" ? command.split(" ") : command; this.cmd = command.join(" "); return [command[0], command.slice(1)]; } /** * @description Resolves the promise with a success message. * @summary Logs a success message and resolves the promise with the given reason. * * @param reason - The reason for resolving the promise. */ resolve(reason) { this.log("stdout", `${this.cmd} executed successfully: ${style(reason ? "ran to completion" : reason).green}`); this.lock.resolve(reason); } /** * @description Rejects the promise with an error message. * @summary Logs an error message and rejects the promise with the given reason. * * @param reason - The reason for rejecting the promise, either a number (exit code) or a string. */ reject(reason) { if (!(reason instanceof Error)) { reason = new Error(typeof reason === "number" ? `Exit code ${reason}` : reason); } this.log("stderr", `${this.cmd} failed to execute: ${style(reason.message).red}`); this.lock.reject(reason); } } /** * @description Creates a locked version of a function. * @summary This higher-order function takes a function and returns a new function that ensures * sequential execution of the original function, even when called multiple times concurrently. * It uses a Promise-based locking mechanism to queue function calls. * * @template R - The return type of the input function. * * @param f - The function to be locked. It can take any number of parameters and return a value of type R. * @return A new function with the same signature as the input function, but with sequential execution guaranteed. * * @function lockify * * @mermaid * sequenceDiagram * participant Caller * participant LockedFunction * participant OriginalFunction * Caller->>LockedFunction: Call with params * LockedFunction->>LockedFunction: Check current lock * alt Lock is resolved * LockedFunction->>OriginalFunction: Execute with params * OriginalFunction-->>LockedFunction: Return result * LockedFunction-->>Caller: Return result * else Lock is pending * LockedFunction->>LockedFunction: Queue execution * LockedFunction-->>Caller: Return promise * Note over LockedFunction: Wait for previous execution * LockedFunction->>OriginalFunction: Execute with params * OriginalFunction-->>LockedFunction: Return result * LockedFunction-->>Caller: Resolve promise with result * end * LockedFunction->>LockedFunction: Update lock * * @memberOf module:utils */ function lockify(f) { let lock = Promise.resolve(); return (...params) => { const result = lock.then(() => f(...params)); lock = result.catch(() => { }); return result; }; } function chainAbortController(argument0, ...remainder) { let signals; let controller; // normalize args if (argument0 instanceof AbortSignal) { controller = new AbortController(); signals = [argument0, ...remainder]; } else { controller = argument0; signals = remainder; } // if the controller is already aborted, exit early if (controller.signal.aborted) { return controller; } const handler = () => controller.abort(); for (const signal of signals) { // check before adding! (and assume there is no possible way that the signal could // abort between the `if` check and adding the event listener) if (signal.aborted) { controller.abort(); break; } signal.addEventListener("abort", handler, { once: true, signal: controller.signal, }); } return controller; } /** * @description Spawns a command as a child process with output handling. * @summary Creates a child process to execute a command with support for piping multiple commands, * custom output handling, and abort control. This function handles the low-level details of * spawning processes and connecting their inputs/outputs when piping is used. * * @template R - The type of the processed output, defaulting to string. * @param {StandardOutputWriter<R>} output - The output writer to handle command output. * @param {string} command - The command to execute, can include pipe operators. * @param {SpawnOptionsWithoutStdio} opts - Options for the spawned process. * @param {AbortController} abort - Controller to abort the command execution. * @param {Logger} logger - Logger for recording command execution details. * @return {ChildProcessWithoutNullStreams} The spawned child process. * * @function spawnCommand * * @memberOf module:utils */ function spawnCommand(output, command, opts, abort, logger) { function spawnInner(command, controller) { const [cmd, argz] = output.parseCommand(command); logger.info(`Running command: ${cmd}`); logger.debug(`with args: ${argz.join(" ")}`); const childProcess = spawn(cmd, argz, { ...opts, cwd: opts.cwd || process.cwd(), env: Object.assign({}, process.env, opts.env, { PATH: process.env.PATH }), shell: opts.shell || false, signal: controller.signal, }); logger.verbose(`pid : ${childProcess.pid}`); return childProcess; } const m = command.match(/[<>$#]/g); if (m) throw new Error(`Invalid command: ${command}. contains invalid characters: ${m}`); if (command.includes(" | ")) { const cmds = command.split(" | "); const spawns = []; const controllers = new Array(cmds.length); controllers[0] = abort; for (let i = 0; i < cmds.length; i++) { if (i !== 0) controllers[i] = chainAbortController(controllers[i - 1].signal); spawns.push(spawnInner(cmds[i], controllers[i])); if (i === 0) continue; spawns[i - 1].stdout.pipe(spawns[i].stdin); } return spawns[cmds.length - 1]; } return spawnInner(command, abort); } /** * @description Executes a command asynchronously with customizable output handling. * @summary This function runs a shell command as a child process, providing fine-grained * control over its execution and output handling. It supports custom output writers, * allows for command abortion, and captures both stdout and stderr. * * @template R - The type of the resolved value from the command execution. * * @param command - The command to run, either as a string or an array of strings. * @param opts - Spawn options for the child process. Defaults to an empty object. * @param outputConstructor - Constructor for the output writer. Defaults to StandardOutputWriter. * @param args - Additional arguments to pass to the output constructor. * @return {CommandResult} A promise that resolves to the command result of type R. * * @function runCommand * * @mermaid * sequenceDiagram * participant Caller * participant runCommand * participant OutputWriter * participant ChildProcess * Caller->>runCommand: Call with command and options * runCommand->>OutputWriter: Create new instance * runCommand->>OutputWriter: Parse command * runCommand->>ChildProcess: Spawn process * ChildProcess-->>runCommand: Return process object * runCommand->>ChildProcess: Set up event listeners * loop For each stdout data * ChildProcess->>runCommand: Emit stdout data * runCommand->>OutputWriter: Handle stdout data * end * loop For each stderr data * ChildProcess->>runCommand: Emit stderr data * runCommand->>OutputWriter: Handle stderr data * end * ChildProcess->>runCommand: Emit error (if any) * runCommand->>OutputWriter: Handle error * ChildProcess->>runCommand: Emit exit * runCommand->>OutputWriter: Handle exit * OutputWriter-->>runCommand: Resolve or reject promise * runCommand-->>Caller: Return CommandResult * * @memberOf module:utils */ function runCommand(command, opts = {}, outputConstructor = (StandardOutputWriter), ...args) { const logger = Logging.for(runCommand); const abort = new AbortController(); const result = { abort: abort, command: command, logs: [], errs: [], }; const lock = new Promise((resolve, reject) => { let output; try { output = new outputConstructor(command, { resolve, reject, }, ...args); result.cmd = spawnCommand(output, command, opts, abort, logger); } catch (e) { return reject(new Error(`Error running command ${command}: ${e}`)); } result.cmd.stdout.setEncoding("utf8"); result.cmd.stdout.on("data", (chunk) => { chunk = chunk.toString(); result.logs.push(chunk); output.data(chunk); }); result.cmd.stderr.on("data", (data) => { data = data.toString(); result.errs.push(data); output.error(data); }); result.cmd.once("error", (err) => { output.exit(err.message, result.errs); }); result.cmd.once("exit", (code = 0) => { if (abort.signal.aborted && code === null) code = AbortCode; output.exit(code, code === 0 ? result.logs : result.errs); }); }); Object.assign(result, { promise: lock, pipe: async (cb) => { const l = logger.for("pipe"); try { l.verbose(`Executing pipe function ${command}...`); const result = await lock; l.verbose(`Piping output to ${cb.name}: ${result}`); return cb(result); } catch (e) { l.error(`Error piping command output: ${e}`); throw e; } }, }); return result; } const logger = Logging.for("fs"); /** * @description Patches a file with given values. * @summary Reads a file, applies patches using TextUtils, and writes the result back to the file. * * @param {string} path - The path to the file to be patched. * @param {Record<string, number | string>} values - The values to patch into the file. * @return {void} * * @function patchFile * * @mermaid * sequenceDiagram * participant Caller * participant patchFile * participant fs * participant readFile * participant TextUtils * participant writeFile * Caller->>patchFile: Call with path and values * patchFile->>fs: Check if file exists * patchFile->>readFile: Read file content * readFile->>fs: Read file * fs-->>readFile: Return file content * readFile-->>patchFile: Return file content * patchFile->>TextUtils: Patch string * TextUtils-->>patchFile: Return patched content * patchFile->>writeFile: Write patched content * writeFile->>fs: Write to file * fs-->>writeFile: File written * writeFile-->>patchFile: File written * patchFile-->>Caller: Patching complete * * @memberOf module:utils */ function patchFile(path, values) { const log = logger.for(patchFile); if (!fs.existsSync(path)) throw new Error(`File not found at path "${path}".`); let content = readFile(path); try { log.verbose(`Patching file "${path}"...`); log.debug(`with value: ${JSON.stringify(values)}`); content = patchString(content, values); } catch (error) { throw new Error(`Error patching file: ${error}`); } writeFile(path, content); } /** * @description Reads a file and returns its content. * @summary Reads the content of a file at the specified path and returns it as a string. * * @param {string} path - The path to the file to be read. * @return {string} The content of the file. * * @function readFile * * @memberOf module:utils */ function readFile(path) { const log = logger.for(readFile); try { log.verbose(`Reading file "${path}"...`); return fs.readFileSync(path, "utf8"); } catch (error) { log.verbose(`Error reading file "${path}": ${error}`); throw new Error(`Error reading file "${path}": ${error}`); } } /** * @description Writes data to a file. * @summary Writes the provided data to a file at the specified path. * * @param {string} path - The path to the file to be written. * @param {string | Buffer} data - The data to be written to the file. * @return {void} * * @function writeFile * * @memberOf module:utils */ function writeFile(path, data) { const log = logger.for(writeFile); try { log.verbose(`Writing file "${path} with ${data.length} bytes...`); fs.writeFileSync(path, data, "utf8"); } catch (error) { log.verbose(`Error writing file "${path}": ${error}`); throw new Error(`Error writing file "${path}": ${error}`); } } /** * @description Retrieves all files recursively from a directory. * @summary Traverses through directories and subdirectories to collect all file paths. * * @param {string} p - The path to start searching from. * @param {function} [filter] - Optional function to filter files by name or index. * @return {string[]} Array of file paths. * * @function getAllFiles * * @memberOf module:utils */ function getAllFiles(p, filter) { const log = logger.for(getAllFiles); const files = []; try { log.verbose(`Retrieving all files from "${p}"...`); const entries = fs.readdirSync(p); entries.forEach((entry) => {