termost
Version:
Get the most of your terminal
558 lines (546 loc) • 16.2 kB
JavaScript
import { spawn } from "node:child_process";
import pico from "picocolors";
import enquirer from "enquirer";
import { Listr, PRESET_TIMER } from "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 = 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 = [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 { 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;
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 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) {
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
export { helpers, termost };