@decaf-ts/utils
Version:
module management utils for decaf-ts
1,276 lines (1,269 loc) • 477 kB
JavaScript
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) => {