UNPKG

termost

Version:
619 lines (605 loc) 20.3 kB
'use strict'; var pico = require('picocolors'); var process = require('node:process'); var node_child_process = require('node:child_process'); var listr2 = require('listr2'); var enquirer = require('enquirer'); /** * A helper to format an arbitrary text as a message input. * @param message - The text to display. * @param options - The configuration object to control the formatting properties. * @param options.color - The color to apply. * @param options.modifiers - The modifiers to apply (can be italic, bold, ...). * @returns The formatted text. * @example * const formattedMessage = format("my message"); */ const format = (message, options = {})=>{ const { color = "white", modifiers = [] } = options; const transformers = []; transformers.push(pico[colorMapper[color]]); modifiers.forEach((modifier)=>{ if (modifier === "uppercase") { message = message.toUpperCase(); } else if (modifier === "lowercase") { message = message.toLowerCase(); } else { transformers.push(pico[modifierMapper[modifier]]); } }); return compose(...transformers)(message); }; /** * An opinionated helper to display arbitrary text on the console. * @param content - The content to display. A content can be either a string or an error. * @param options - The configuration object to define the display type and/or override the default label. * @param options.label - The label to display. * @param options.type - The message type. * @param options.lineBreak - Configure line break addition. * @example * message("message to log"); */ const message = (content, { label: optionLabel, lineBreak: optionlineBreak, type: optionType } = {})=>{ const isTextualContent = typeof content === "string"; const type = optionType ?? (isTextualContent ? "information" : "error"); const { color, defaultLabel, icon, method } = formatPropertiesByType[type]; const hasNoLabel = optionLabel === false; const getLineBreak = ()=>{ if (optionlineBreak === undefined) { return { end: false, start: false }; } if (isRecord(optionlineBreak)) { return optionlineBreak; } return { end: optionlineBreak, start: optionlineBreak }; }; const getLabel = ()=>{ if (hasNoLabel) { return isTextualContent ? content : content.message; } return optionLabel ?? defaultLabel; }; const lineBreak = getLineBreak(); const output = [ format(`${lineBreak.start ? "\n" : ""}${icon} ${getLabel()}`, { color, modifiers: [ "bold" ] }), !hasNoLabel && isTextualContent ? format(` ${content}`, { color }) : undefined, !isTextualContent ? content : undefined ].filter(Boolean); output.forEach((item)=>{ method(item); }); if (lineBreak.end) { method(); } }; const isRecord = (value)=>{ return typeof value === "object" && value !== null && !Array.isArray(value); }; const compose = (...functions)=>{ if (!functions[0]) throw new Error("No function is provided, defeating the purpose of composing functions. Make sure to provide at least one function as an argument."); return functions.reduce((previousFunction, nextFunction)=>(value)=>previousFunction(nextFunction(value)), functions[0]); }; const formatPropertiesByType = { error: { color: "red", defaultLabel: "Error", icon: "❌", method: console.error }, information: { color: "blue", defaultLabel: "Information", icon: "ℹ️", method: console.info }, success: { color: "green", defaultLabel: "Success", icon: "✅", method: console.log }, warning: { color: "yellow", defaultLabel: "Warning", icon: "⚠️ ", method: console.warn } }; const colorMapper = { black: "black", blue: "blue", cyan: "cyan", green: "green", grey: "gray", magenta: "magenta", red: "red", white: "white", yellow: "yellow" }; const modifierMapper = { bold: "bold", italic: "italic", strikethrough: "strikethrough", underline: "underline" }; const exec = async (command, options = {})=>{ const { cwd, hasLiveOutput = false } = options; return new Promise((resolve, reject)=>{ let stdout = ""; let stderr = ""; const [bin, ...arguments_] = command.split(" "); // eslint-disable-next-line sonarjs/os-command const childProcess = node_child_process.spawn(bin, arguments_, { cwd, env: { // eslint-disable-next-line n/no-process-env ...process.env, // @note: make sure to force color display for spawned processes FORCE_COLOR: "1" }, shell: true, stdio: hasLiveOutput ? "inherit" : "pipe" }); childProcess.stdout?.on("data", (chunk)=>{ stdout += chunk; }); childProcess.stderr?.on("data", (chunk)=>{ stderr += chunk; }); childProcess.on("close", (exitCode)=>{ if (exitCode !== 0) { const output = `${stderr}${stdout}`; reject(new Error(output.trim())); } else { resolve(stdout.trim()); } }); }); }; const getArguments = ()=>{ const parameters = process.argv.slice(2); let command; const operands = []; const options = {}; let currentOptionName; const addOptimisticOption = (name, value)=>{ if (value) { options[name] = typeof value === "string" ? castValue(value) : true; } else { currentOptionName = name; } }; const flushOptimisticOption = ()=>{ if (currentOptionName) { options[currentOptionName] = true; currentOptionName = undefined; } }; for (const parameter of parameters){ const shortFlagMatchResult = SHORT_FLAG_REGEX.exec(parameter)?.groups; const longFlagMatchResult = LONG_FLAG_REGEX.exec(parameter)?.groups; if (shortFlagMatchResult?.name) { flushOptimisticOption(); const optionFlags = [ ...shortFlagMatchResult.name ]; const lastIndex = optionFlags.length - 1; optionFlags.forEach((flag, index)=>{ addOptimisticOption(flag, lastIndex === index ? undefined : true); }); } else if (longFlagMatchResult?.name) { flushOptimisticOption(); addOptimisticOption(longFlagMatchResult.name, longFlagMatchResult.value); } else if (currentOptionName) { options[currentOptionName] = castValue(parameter); currentOptionName = undefined; } else if (!command) { command = parameter; } else { operands.push(parameter); } } flushOptimisticOption(); return { command, operands, options }; }; const SHORT_FLAG_REGEX = /^-(?<name>(?!-).*)$/; const LONG_FLAG_REGEX = /^--(?<name>.*?)(?:=(?<value>.+))?$/; const castValue = (value)=>{ try { return JSON.parse(value); } catch { return value; } }; const createTask = (parameters)=>{ const { key, label, handler } = parameters; const receiver = label ? new listr2.Listr([], { collectErrors: "minimal", exitOnError: true, rendererOptions: { collapseErrors: false, formatOutput: "wrap", showErrorMessage: false, timer: listr2.PRESET_TIMER } }) : null; return async function execute(context, argv) { let value; if (!receiver) { value = await handler(context, argv); } else { receiver.add({ ...label && { title: typeof label === "function" ? label(context, argv) : label }, // eslint-disable-next-line @typescript-eslint/no-unsafe-return task: async ()=>value = await handler(context, argv) }); await receiver.run(); } return { key, value }; }; }; /* eslint-disable @typescript-eslint/no-unsafe-assignment */ const createOption = (commandController, { argv })=>(parameters)=>{ const { key, name, description, defaultValue } = parameters; const aliases = typeof name === "string" ? [ name ] : [ name.short, name.long ]; const metadataKey = aliases.map((alias, index)=>"-".repeat(aliases.length > 1 ? index + 1 : 2) + alias).join(", "); commandController.addOptionDescription(metadataKey, description); return async function execute() { let value; for (const alias of aliases){ if (alias in argv.options) { value = argv.options[alias]; break; } } // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject return Promise.resolve({ key, value: value ?? defaultValue }); }; }; const { prompt } = enquirer; const createInput = (parameters)=>{ const { key, label, defaultValue, type } = parameters; return async function execute(context, argv) { const promptObject = { name: key, initial: defaultValue, message: typeof label === "function" ? label(context, argv) : label, type }; if (parameters.type === "select" || parameters.type === "multiselect") { const isMultiSelect = parameters.type === "multiselect"; const options = parameters.options; const choices = options.map((option)=>({ title: option, value: option, ...isMultiSelect && { selected: (defaultValue || []).includes(option) } })); promptObject.choices = choices; } const data = await prompt(promptObject); return { key, value: data[key] }; }; }; const createQueue = ()=>{ const items = []; return { dequeue () { return items.shift(); }, enqueue (item) { items.push(item); }, isEmpty () { return items.length === 0; } }; }; const getCommandController = (name)=>{ const controller = commandControllerCollection[name]; if (!controller) { throw new Error(`No controller has been set for the \`${name}\` command.\nHave you run the \`termost\` constructor?`); } return controller; }; const createCommandController = (name, description)=>{ const instructions = createQueue(); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions let context = {}; const metadata = { description, options: { "-h, --help": "Display the help center", "-v, --version": "Print the version" } }; const controller = { addInstruction (instruction) { instructions.enqueue(instruction); }, addOptionDescription (key, value) { metadata.options[key] = value; }, addValue (key, value) { context[key] = value; }, async enable () { while(!instructions.isEmpty()){ const task = instructions.dequeue(); if (task) { await task(); } } }, getContext (rootCommandName) { /** * By design, global values are accessible to subcommands. * Consequently, root command values are merged with the current command ones. */ if (name !== rootCommandName) { const rootController = getCommandController(rootCommandName); const globalContext = rootController.getContext(rootCommandName); context = { ...globalContext, ...context }; } return context; }, getMetadata (rootCommandName) { if (name !== rootCommandName) { const globalMetadata = getCommandController(rootCommandName).getMetadata(rootCommandName); metadata.options = { ...globalMetadata.options, ...metadata.options }; } return metadata; } }; commandDescriptionCollection[name] = description; commandControllerCollection[name] = controller; return controller; }; const getCommandDescriptionCollection = ()=>{ return commandDescriptionCollection; }; const commandControllerCollection = {}; const commandDescriptionCollection = {}; const createCommand = ({ name, description }, metadata)=>{ const { name: rootCommandName, argv, version } = metadata; const isRootCommand = name === rootCommandName; const isActiveCommand = argv.command === name; const controller = createCommandController(name, description); const rootController = getCommandController(rootCommandName); /* * Timeout to force evaluating help output at the end of the program instructions chaining. * It allows collecting all needed input to fill the output: */ setTimeout(()=>{ /** * By design, the root command instructions are always executed * even with subcommands (to share options, messages...). */ if (isRootCommand && !isActiveCommand) { void rootController.enable(); } // Enable the current active command instructions: if (isActiveCommand) { /** * SetTimeout 0 allows to run activation logic in the next event loop iteration. * It'll allow to make sure that the `metadata` is correctly filled with all commands * metadata (especially to let the global help option to display all available commands). */ const optionKeys = Object.keys(argv.options); const help = ()=>{ showHelp({ controller, currentCommandName: name, isRootCommand, rootCommandName }); }; if (optionKeys.includes(OPTION_VERSION_NAMES[0]) || optionKeys.includes(OPTION_VERSION_NAMES[1])) { console.info(version); return; } if (optionKeys.includes(OPTION_HELP_NAMES[0]) || optionKeys.includes(OPTION_HELP_NAMES[1])) { help(); return; } if (metadata.isEmptyCommand[name]) { // Show help by default if no processing is done for the current command help(); } else { void controller.enable(); } } }, 0); return name; }; const OPTION_HELP_NAMES = [ "help", "h" ]; const OPTION_VERSION_NAMES = [ "version", "v" ]; const showHelp = ({ controller, currentCommandName, isRootCommand, rootCommandName })=>{ const commandMetadata = controller.getMetadata(rootCommandName); const { description, options } = commandMetadata; const commands = getCommandDescriptionCollection(); const optionKeys = Object.keys(commandMetadata.options); const commandKeys = Object.keys(commands); const hasOptions = optionKeys.length > 0; const hasCommands = isRootCommand && commandKeys.length > 1; printTitle("Usage"); print(`${format(`${rootCommandName}${isRootCommand ? "" : ` ${String(currentCommandName)}`}`, { color: "green" })} ${hasCommands ? "<command> " : ""}${hasOptions ? "[…options]" : ""}`); if (description) { printTitle("Description"); print(description); } const padding = [ ...commandKeys, ...optionKeys ].reduce((value, item)=>{ return Math.max(value, item.length); }, 0); if (hasCommands) { printTitle("Commands"); for (const name of commandKeys){ if (name === rootCommandName) continue; const commandDescription = commands[name]; if (commandDescription) printLabelValue(name, commandDescription, padding); } } if (hasOptions) { printTitle("Options"); for (const key of optionKeys){ printLabelValue(key, options[key], padding); } } }; const print = (...parameters)=>{ console.log(format(...parameters)); }; const printTitle = (message)=>{ print(`\n${message}:`, { color: "yellow", modifiers: [ "bold", "underline", "uppercase" ] }); }; const printLabelValue = (label, value, padding)=>{ print(` ${format(label.padEnd(padding + 1, " "), { color: "green" })} ${value}`); }; function termost({ name, description, onException, onShutdown, version }) { const { command = name, operands, options } = getArguments(); setGracefulListeners({ onException, onShutdown }); return createProgram({ name, description, argv: { command, operands, options }, isEmptyCommand: {}, version }); } const createProgram = (metadata)=>{ const { name, description, argv } = metadata; const rootCommandName = name; let currentCommandName = rootCommandName; const createInstruction = (factory, parameters)=>{ const instruction = factory(parameters); const controller = getCommandController(currentCommandName); controller.addInstruction(async ()=>{ const { skip } = parameters; const context = controller.getContext(rootCommandName); if (skip?.(context, argv)) return; const output = await instruction(context, argv); if (!output || !output.key) return; controller.addValue(output.key, output.value); }); }; const program = { command (parameters) { currentCommandName = createCommand(parameters, metadata); metadata.isEmptyCommand[currentCommandName] = true; // This flag is disabled only for instructions that introduce stdio side effects (`option` instructions are so ignored). return this; }, input (parameters) { createInstruction(createInput, parameters); metadata.isEmptyCommand[currentCommandName] = false; return this; }, option (parameters) { createInstruction(createOption(getCommandController(currentCommandName), metadata), parameters); return this; }, task (parameters) { createInstruction(createTask, parameters); metadata.isEmptyCommand[currentCommandName] = false; return this; } }; // @note: the root command is created by default program.command({ name, description }); return program; }; const setGracefulListeners = ({ onException = ()=>{ return; }, onShutdown = ()=>{ return; } })=>{ process.on("SIGTERM", ()=>{ onShutdown(); process.exit(0); }); process.on("SIGINT", ()=>{ onShutdown(); process.exit(0); }); process.on("uncaughtException", (error)=>{ onException(error); message(error); process.exit(1); }); process.on("unhandledRejection", (reason)=>{ if (reason instanceof Error) { onException(reason); message(reason); } process.exit(1); }); }; const helpers = { exec, format, message }; exports.helpers = helpers; exports.termost = termost;