UNPKG

termost

Version:

Get the most of your terminal

589 lines (576 loc) 17.5 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); //#region \0rolldown/runtime.js var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) { __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } } } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion let node_child_process = require("node:child_process"); let picocolors = require("picocolors"); picocolors = __toESM(picocolors, 1); let enquirer = require("enquirer"); enquirer = __toESM(enquirer, 1); let listr2 = require("listr2"); //#region src/helpers/process/index.ts const exec = async (command, options = {}) => { const { cwd, hasLiveOutput = false } = options; return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; const childProcess = (0, node_child_process.spawn)(command, { cwd, env: { ...process.env, 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) resolve(stdout.trim()); else { const output = `${stderr}${stdout}`; reject(new Error(output.trim())); } }); }); }; //#endregion //#region src/helpers/stdout/index.ts /** * 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 = [picocolors.default[colorMapper[color]]]; modifiers.forEach((modifier) => { if (modifier === "uppercase") message = message.toUpperCase(); else if (modifier === "lowercase") message = message.toLowerCase(); else transformers.push(picocolors.default[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 { color, defaultLabel, icon, method } = formatPropertiesByType[optionType ?? (isTextualContent ? "information" : "error")]; const hasNoLabel = optionLabel === false; const getLineBreak = () => { if (optionlineBreak === void 0) 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(); [ format(`${lineBreak.start ? "\n" : ""}${icon} ${getLabel()}`, { color, modifiers: ["bold"] }), !hasNoLabel && isTextualContent ? format(` ${content}`, { color }) : void 0, isTextualContent ? void 0 : content ].filter(Boolean).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" }; //#endregion //#region src/api/command/controller/queue.ts const createQueue = () => { const items = []; return { dequeue() { return items.shift(); }, enqueue(item) { items.push(item); }, isEmpty() { return items.length === 0; } }; }; //#endregion //#region src/api/command/controller/index.ts 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(); 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) context = { ...getCommandController(rootCommandName).getContext(rootCommandName), ...context }; return context; }, getMetadata(rootCommandName) { if (name !== rootCommandName) metadata.options = { ...getCommandController(rootCommandName).getMetadata(rootCommandName).options, ...metadata.options }; return metadata; } }; commandDescriptionCollection[name] = description; commandControllerCollection[name] = controller; return controller; }; const getCommandDescriptionCollection = () => { return commandDescriptionCollection; }; const commandControllerCollection = {}; const commandDescriptionCollection = {}; //#endregion //#region src/api/command/command.ts const createCommand = ({ description, name }, metadata) => { const { argv, name: rootCommandName, version } = metadata; const isRootCommand = name === rootCommandName; const isActiveCommand = argv.command === name; const controller = createCommandController(name, description); const rootController = getCommandController(rootCommandName); setTimeout(() => { /** * By design, the root command instructions are always executed * even with subcommands (to share options, messages...). */ if (isRootCommand && !isActiveCommand) rootController.enable(); 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]) help(); else 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}`); }; //#endregion //#region src/api/input/index.ts const { prompt } = enquirer.default; const createInput = (parameters) => { const { defaultValue, key, label, type } = parameters; const mappedPromptType = type === "select" || type === "multiselect" ? "autocomplete" : type; return async function execute(context, argv) { const promptObject = { initial: defaultValue, message: typeof label === "function" ? label(context, argv) : label, name: key, type: mappedPromptType }; if (parameters.type === "select" || parameters.type === "multiselect") { const isMultiSelect = parameters.type === "multiselect"; const choices = parameters.options.map((option) => ({ multiple: isMultiSelect, title: option, ...isMultiSelect && { selected: (defaultValue ?? []).includes(option) }, value: option })); promptObject.limit = 10; promptObject.multiple = isMultiSelect; promptObject.choices = choices; } return { key, value: (await prompt(promptObject))[key] }; }; }; //#endregion //#region src/api/option/index.ts const createOption = (commandController, { argv }) => (parameters) => { const { defaultValue, description, key, name } = 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; } return Promise.resolve({ key, value: value ?? defaultValue }); }; }; //#endregion //#region src/api/task/index.ts const createTask = (parameters) => { const { handler, key, label } = 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) { receiver.add({ ...label && { title: typeof label === "function" ? label(context, argv) : label }, task: async () => value = await handler(context, argv) }); await receiver.run(); } else value = await handler(context, argv); return { key, value }; }; }; //#endregion //#region src/helpers/stdin/index.ts 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 = void 0; } }; 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 ? void 0 : true); }); } else if (longFlagMatchResult?.name) { flushOptimisticOption(); addOptimisticOption(longFlagMatchResult.name, longFlagMatchResult.value); } else if (currentOptionName) { options[currentOptionName] = castValue(parameter); currentOptionName = void 0; } else if (command) operands.push(parameter); else command = 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; } }; //#endregion //#region src/termost.ts function termost({ description, name, onException, onShutdown, version }) { const { command = name, operands, options } = getArguments(); const metadata = { argv: { command, operands, options }, description, isEmptyCommand: {}, name, version }; setGracefulListeners({ onException, onShutdown }); return createProgram(metadata); } const createProgram = (metadata) => { const { argv, description, name } = metadata; const rootCommandName = name; let currentCommandName = rootCommandName; const createInstruction = (factory, parameters) => { const instruction = factory(parameters); const controller = getCommandController(currentCommandName); controller.addInstruction(async () => { const { skip, validate } = parameters; const context = controller.getContext(rootCommandName); if (skip?.(context, argv)) return; const output = await instruction(context, argv); if (!output?.key) return; controller.addValue(output.key, output.value); const error = validate?.(controller.getContext(rootCommandName), argv); if (!error) return; throw error; }); }; const program = { command(parameters) { currentCommandName = createCommand(parameters, metadata); metadata.isEmptyCommand[currentCommandName] = true; 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; } }; program.command({ description, name }); return program; }; const setGracefulListeners = ({ onException = () => {}, onShutdown = () => {} }) => { process.on("SIGTERM", () => { onShutdown(); process.exit(0); }); process.on("SIGINT", () => { onShutdown(); process.exit(0); }); process.on("uncaughtException", (error) => { message(error, { lineBreak: true }); onException(error); process.exit(1); }); process.on("unhandledRejection", (reason) => { if (reason instanceof Error) { message(reason, { lineBreak: true }); onException(reason); } process.exit(1); }); }; //#endregion //#region src/index.ts const helpers = { exec, format, message }; //#endregion exports.helpers = helpers; exports.termost = termost;