termost
Version:
Get the most of your terminal
616 lines (603 loc) • 20.2 kB
JavaScript
import pico from 'picocolors';
import process from 'node:process';
import { spawn } from 'node:child_process';
import { Listr, PRESET_TIMER } from 'listr2';
import enquirer from '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 = 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 Listr([], {
collectErrors: "minimal",
exitOnError: true,
rendererOptions: {
collapseErrors: false,
formatOutput: "wrap",
showErrorMessage: false,
timer: 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
};
export { helpers, termost };