UNPKG

execli

Version:

Generate task-oriented CLIs declaratively

365 lines 14.3 kB
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