execli
Version:
Generate task-oriented CLIs declaratively
365 lines • 14.3 kB
JavaScript
import { env } from "node:process";
import _isInteractive from "is-interactive";
import { Listr, } from "listr2";
import { createContextHolder, getUserContext, } from "./context.js";
import { ExecError, createExec, getCommandString, } from "./exec.js";
import { getCpuCount } from "./get-cpu-count.js";
export const buildFlatTasks = (task, flatTasks = {}, parentTitle) => {
if (task.title in flatTasks) {
throw new Error(`Two tasks have the same title: ${task.title}`);
}
flatTasks[task.title] = {
...(isParentTask(task)
? { children: task.children.map(({ title }) => title) }
: {}),
parentTitle,
tags: "tags" in task ? task.tags : undefined,
};
if (isParentTask(task)) {
for (const childTask of Object.values(task.children)) {
buildFlatTasks(childTask, flatTasks, task.title);
}
}
return flatTasks;
};
const getAncestorTitles = (flatTasks, taskTitle) => {
const ancestorTitles = new Set();
let currentTitle = taskTitle;
for (;;) {
currentTitle = flatTasks[currentTitle].parentTitle;
if (currentTitle === undefined) {
break;
}
else {
ancestorTitles.add(currentTitle);
}
}
return ancestorTitles;
};
const buildStaticallySkippedTasks = (context, flatTasks, taskTitle, staticallySkippedTasks = {}, ancestorMatchedOnlyOption = false, isRootCall = true) => {
const matchedOnlyOption = ancestorMatchedOnlyOption || context.only.includes(taskTitle);
const task = flatTasks[taskTitle];
if (isRootCall &&
(context.from !== undefined || context.until !== undefined)) {
const orderedTaskTitles = Object.keys(flatTasks);
if (context.until !== undefined) {
for (const title of orderedTaskTitles.slice(orderedTaskTitles.indexOf(context.until) + 1)) {
staticallySkippedTasks[title] = "until";
}
}
if (context.from !== undefined) {
const ancestorTitles = getAncestorTitles(flatTasks, context.from);
for (const title of orderedTaskTitles
.slice(0, orderedTaskTitles.indexOf(context.from))
.filter((title) => !staticallySkippedTasks[title] && !ancestorTitles.has(title))) {
staticallySkippedTasks[title] = "from";
}
}
}
if (staticallySkippedTasks[taskTitle]) {
// NOP
}
else if (context.skip.length > 0 && context.skip.includes(taskTitle)) {
staticallySkippedTasks[taskTitle] = "skip";
}
else if ("children" in task) {
for (const childTaskTitle of task.children) {
buildStaticallySkippedTasks(context, flatTasks, childTaskTitle, staticallySkippedTasks, matchedOnlyOption, false);
}
}
else if (context.only.length > 0 && !matchedOnlyOption) {
staticallySkippedTasks[taskTitle] = "only";
}
else if (context.tag.length > 0 &&
context.tag.every((givenTag) => !(task?.tags ?? []).includes(givenTag))) {
staticallySkippedTasks[taskTitle] = "tag";
}
return staticallySkippedTasks;
};
const getSkipReason = (option) => `Skipped by --${option} option`;
const skipByOnlyOption = (skippedTasks, title) => (skippedTasks[title] === "only" ? getSkipReason("only") : false);
const skipByTagOption = (skippedTasks, title) => skippedTasks[title] === "tag" ? getSkipReason("tag") : false;
const skipByOnlyOrTagOptions = (skippedTasks, title) => skipByOnlyOption(skippedTasks, title) || skipByTagOption(skippedTasks, title);
const shouldSkipByTaskProperty = (context, task) => {
if (!task.skip) {
return false;
}
let result;
if ("task" in task) {
// Task is a ListrTask.
result = typeof task.skip === "function" ? task.skip() : task.skip;
}
else {
// Task is as SkippableTask.
result = task.skip(getUserContext(context));
}
return result;
};
const skipCommandTask = (context, skippedTasks, task) => skipByOnlyOrTagOptions(skippedTasks, task.title) ||
shouldSkipByTaskProperty(context, task);
const addDetailsToTaskTitle = (title, details) => `${title} (${details})`;
const processCommandProperties = (commandProperties, context, title, taskWrapper) => {
const userContext = getUserContext(context);
let command;
let options;
if (context.dryRun &&
(typeof commandProperties.command === "function" ||
typeof commandProperties.options === "function")) {
const proxiedUserContext = new Proxy(userContext, {
get(proxied, key) {
if (key in proxied) {
// @ts-expect-error: The line above ensures that key is in proxied.
return proxied[key];
}
return "__contextual";
},
});
try {
command =
typeof commandProperties.command === "function"
? commandProperties.command(proxiedUserContext)
: commandProperties.command;
options =
typeof commandProperties.options === "function"
? commandProperties.options(proxiedUserContext)
: commandProperties.options ?? {};
}
catch {
taskWrapper.title = addDetailsToTaskTitle(title, "contextual command");
taskWrapper.skip(getSkipReason("dryRun"));
return;
}
}
if (command === undefined) {
command =
typeof commandProperties.command === "function"
? commandProperties.command(userContext)
: commandProperties.command;
}
if (options === undefined) {
options =
typeof commandProperties.options === "function"
? commandProperties.options(userContext)
: commandProperties.options ?? {};
}
if (context.dryRun) {
const commandString = getCommandString(command, context, options);
taskWrapper.title = addDetailsToTaskTitle(title, `$ ${commandString}`);
taskWrapper.skip(getSkipReason("dryRun"));
return;
}
return { command, options };
};
const isInteractive = _isInteractive();
const isUsingVerboseRenderer = ({ concurrency, }) => concurrency === 0 || !isInteractive;
const processLine = (line, { concurrency, taskTitle, }) => {
if (/\r?\n/.test(line)) {
throw new Error(`Expected single line but got some line breaks in:\n\n: ${line}`);
}
return concurrency > 0 && isUsingVerboseRenderer({ concurrency })
? `${taskTitle}: ${line}`
: line;
};
const createOutputLine = (taskWrapper, { concurrency }) => (line) => {
if (/\r?\n/.test(line)) {
throw new Error(`Output line cannot contain line break:\n\n: ${line}`);
}
taskWrapper.output = processLine(line, {
concurrency,
taskTitle: taskWrapper.title,
});
};
const createSkippableTask = (context, skippedTasks, task) => ({
...task,
async skip() {
const taskTitle = task.title;
const option = skippedTasks[taskTitle];
if (option === "from" || option === "skip" || option === "until") {
return processLine(getSkipReason(option), {
concurrency: context.concurrency,
taskTitle,
});
}
const skipResult = await shouldSkipByTaskProperty(context, task);
return (skipResult &&
processLine(skipResult === true ? "Task skipped itself" : skipResult, {
concurrency: context.concurrency,
taskTitle,
}));
},
});
const isCommandTask = (task) => "command" in task;
const isRegularTask = (task) => "run" in task;
const isParentTask = (task) => "children" in task;
const runRegularTask = async (contextHolder, run, taskWrapper) => {
const context = contextHolder.get();
const outputLine = createOutputLine(taskWrapper, {
concurrency: context.concurrency,
});
const exec = createExec(context, outputLine);
const result = await run({
context: getUserContext(context),
exec,
outputLine,
});
if (result) {
outputLine(result);
}
};
const createCommandTask = (contextHolder, skippedTasks, task) => createSkippableTask(contextHolder.get(), skippedTasks, {
skip: () => skipCommandTask(contextHolder.get(), skippedTasks, task),
async task(_, taskWrapper) {
const context = contextHolder.get();
const commandProperties = processCommandProperties(task, context, task.title, taskWrapper);
if (commandProperties) {
const exec = createExec(context, createOutputLine(taskWrapper, {
concurrency: context.concurrency,
}));
await exec(commandProperties.command, commandProperties.options);
}
},
title: task.title,
});
const createRegularTask = (contextHolder, skippedTasks, task) => createSkippableTask(contextHolder.get(), skippedTasks, {
skip() {
const context = contextHolder.get();
if (context.dryRun) {
return getSkipReason("dryRun");
}
return (skipByOnlyOrTagOptions(skippedTasks, task.title) ||
shouldSkipByTaskProperty(context, task));
},
async task(_, taskWrapper) {
await runRegularTask(contextHolder, task.run, taskWrapper);
},
title: task.title,
});
const getStaticParentTaskSkipReason = (skippedTasks, task) => {
const selfSkipReason = skippedTasks[task.title];
if (selfSkipReason) {
return getSkipReason(selfSkipReason);
}
if (task.children.every((childTask) => isParentTask(childTask)
? getStaticParentTaskSkipReason(skippedTasks, childTask)
: skippedTasks[childTask.title])) {
return "All children are skipped";
}
return false;
};
const cpuCount = getCpuCount();
const createParentTask = (contextHolder, skippedTasks, task) => createSkippableTask(contextHolder.get(), skippedTasks, {
rollback: task.rollback
? async (_, taskWrapper) => {
await runRegularTask(contextHolder,
// @ts-expect-error: The check above ensures that rollback is defined.
task.rollback, taskWrapper);
}
: undefined,
skip() {
const staticSkipReason = getStaticParentTaskSkipReason(skippedTasks, task);
if (staticSkipReason) {
return staticSkipReason;
}
if (!task.skip) {
return false;
}
const context = contextHolder.get();
const userContext = getUserContext(context);
return task.skip(userContext);
},
task() {
const ownContextHolder = contextHolder.copy();
const { concurrency } = ownContextHolder.get();
let childrenTask = new Listr(task.children.map((childTask) => createListrTask(ownContextHolder, skippedTasks,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
childTask)), {
concurrent: task.concurrent && concurrency > 0
? Math.round(cpuCount * concurrency)
: false,
});
if ("addContext" in task) {
const childrenTaskWithAddedContext = childrenTask;
const { addContext, title } = task;
childrenTask = new Listr([
{
async task(_, taskWrapper) {
const context = ownContextHolder.get();
const outputLine = createOutputLine(taskWrapper, {
concurrency: context.concurrency,
});
const exec = createExec(context, outputLine);
const addedContext = await addContext({
addSecret(secret) {
ownContextHolder.addSecret(secret);
},
context: getUserContext(context),
exec,
});
ownContextHolder.add(addedContext);
},
title: `${title} [adding context]`,
},
{
task: () => childrenTaskWithAddedContext,
title: `${title} [with added context]`,
},
]);
}
return childrenTask;
},
title: task.title,
});
// This function is called recursively in functions declared above
// so it has to be hoisted and declared with the "function" keyword.
// eslint-disable-next-line func-style
function createListrTask(contextHolder, skippedTasks, task) {
if (isCommandTask(task)) {
return createCommandTask(contextHolder, skippedTasks, task);
}
if (isRegularTask(task)) {
return createRegularTask(contextHolder, skippedTasks, task);
}
return createParentTask(contextHolder, skippedTasks, task);
}
const isTesting = env.NODE_ENV === "test";
const showTimer = !isTesting;
const defaultRendererOptions = {
collapse: false,
collapseErrors: false,
collapseSkips: false,
showTimer,
};
const verboseRendererOptions = {
showTimer,
useIcons: !isTesting,
};
export const runTask = async (task, context, flatTasks) => {
const internalContext = {
...context,
secrets: [],
};
try {
await new Listr([
createListrTask(createContextHolder(internalContext), buildStaticallySkippedTasks(context, flatTasks, task.title), task),
], isUsingVerboseRenderer(context)
? {
renderer: "verbose",
rendererOptions: verboseRendererOptions,
}
: {
renderer: "default",
rendererOptions: defaultRendererOptions,
}).run();
}
catch (error) {
if (error instanceof ExecError) {
throw error.toDetailedError({
// When `concurrency` is 0, stdout and stderr were already streamed to the terminal.
withOutputs: context.concurrency > 0,
});
}
throw error;
}
};
//# sourceMappingURL=tasks.js.map