From 1404baf1ba44b8136f300d23126f229e0eb04fa6 Mon Sep 17 00:00:00 2001 From: Rafa Mel Date: Fri, 9 Apr 2021 13:17:48 +0200 Subject: [PATCH] feat(tasks): add prompt task --- src/tasks/stdio/confirm.ts | 125 ++++++----------------------- src/tasks/stdio/index.ts | 1 + src/tasks/stdio/prompt.ts | 157 +++++++++++++++++++++++++++++++++++++ src/tasks/stdio/select.ts | 4 +- 4 files changed, 186 insertions(+), 101 deletions(-) create mode 100644 src/tasks/stdio/prompt.ts diff --git a/src/tasks/stdio/confirm.ts b/src/tasks/stdio/confirm.ts index 2bb6955..afec24e 100644 --- a/src/tasks/stdio/confirm.ts +++ b/src/tasks/stdio/confirm.ts @@ -1,18 +1,10 @@ import { Empty, TypeGuard } from 'type-core'; import { shallow } from 'merge-strategies'; -import { createInterface } from 'readline'; -import { into } from 'pipettes'; import { Task } from '../../definitions'; -import { getBadge } from '../../helpers/badges'; -import { isInteractive } from '../../utils/is-interactive'; -import { isCancelled } from '../../utils/is-cancelled'; -import { style } from '../../utils/style'; import { run } from '../../utils/run'; -import { series } from '../aggregate/series'; -import { raises } from '../exception/raises'; +import { isInteractive } from '../../utils/is-interactive'; import { create } from '../creation/create'; -import { print } from '../stdio/print'; -import { log } from '../stdio/log'; +import { prompt } from './prompt'; export interface ConfirmOptions { /** @@ -51,96 +43,31 @@ export function confirm( options || undefined ); - if (!isInteractive(ctx)) { - const message = getBadge('prompt') + ` ${opts.message}`; - return TypeGuard.isBoolean(opts.default) - ? series( - print(message), - log( - 'info', - 'Default selection [non-interactive]:', - style(opts.default ? 'yes' : 'no', { bold: true }) - ), - opts.default ? yes : no - ) - : series( - print(message), - raises( - Error( - `Must provide a default selection on non-interactive contexts` - ) - ) + return prompt( + { + timeout: opts.timeout, + default: TypeGuard.isBoolean(opts.default) + ? opts.default + ? 'yes' + : 'no' + : null, + message: isInteractive(ctx) + ? opts.message + + (opts.default === true ? ' [Y/' : ' [y/') + + (opts.default === false ? 'N]:' : 'n]:') + : opts.message, + validate: (str) => { + return ['y', 'ye', 'yes', 'no', 'n'].includes( + str.trim().toLowerCase() ); - } - - const message = into( - opts.message, - (msg) => msg + (opts.default === true ? ' [Y/' : ' [y/'), - (msg) => msg + (opts.default === false ? 'N]: ' : 'n]: '), - (msg) => getBadge('prompt') + ` ${msg}` + } + }, + async (ctx) => { + const response = (ctx.args[0] || '').toLowerCase(); + const task = response[0] === 'y' ? yes : no; + if (!task) return; + await run(task, { ...ctx, args: ctx.args.slice(1) }); + } ); - - const readline = createInterface({ - input: ctx.stdio[0], - output: ctx.stdio[1] - }); - - let timeout: null | NodeJS.Timeout = null; - const response = await Promise.race([ - new Promise((resolve) => { - ctx.cancellation.finally(() => resolve(null)); - timeout = - opts.timeout < 0 - ? null - : setTimeout(() => resolve(null), opts.timeout); - }), - into(null, function read() { - return new Promise((resolve, reject) => { - readline.question(message, (res) => { - const str = res.trim().toLowerCase(); - if (['y', 'ye', 'yes'].includes(str)) { - return resolve(true); - } - if (['n', 'no'].includes(str)) { - return resolve(false); - } - if (!str && TypeGuard.isBoolean(opts.default)) { - return resolve(opts.default); - } - - isCancelled(ctx).then( - async (cancelled) => { - if (cancelled) return resolve(null); - await run(log('error', 'Invalid response'), ctx); - resolve(read()); - }, - (err) => reject(err) - ); - }); - }); - }) - ]); - - readline.close(); - if (timeout) clearTimeout(timeout); - if (response === null) ctx.stdio[1].write('\n'); - if (await isCancelled(ctx)) return; - - // Explicit response by user - if (response !== null) return response ? yes : no; - - // No response and timeout triggered with a default selection available - if (TypeGuard.isBoolean(opts.default)) { - return series( - log( - 'info', - 'Default selection [timeout]:', - style(opts.default ? 'yes' : 'no', { bold: true }) - ), - opts.default ? yes : no - ); - } - // No response and timeout triggered without a default selection available - return raises(Error(`Confirm timeout: ${opts.timeout}ms`)); }); } diff --git a/src/tasks/stdio/index.ts b/src/tasks/stdio/index.ts index bb5ead5..5f27bcc 100644 --- a/src/tasks/stdio/index.ts +++ b/src/tasks/stdio/index.ts @@ -5,5 +5,6 @@ export * from './interactive'; export * from './log'; export * from './print'; export * from './progress'; +export * from './prompt'; export * from './select'; export * from './silence'; diff --git a/src/tasks/stdio/prompt.ts b/src/tasks/stdio/prompt.ts new file mode 100644 index 0000000..eeec158 --- /dev/null +++ b/src/tasks/stdio/prompt.ts @@ -0,0 +1,157 @@ +import { Empty, TypeGuard, UnaryFn } from 'type-core'; +import { shallow } from 'merge-strategies'; +import { createInterface } from 'readline'; +import { into } from 'pipettes'; +import { Task } from '../../definitions'; +import { getBadge } from '../../helpers/badges'; +import { stringifyError } from '../../helpers/stringify'; +import { isInteractive } from '../../utils/is-interactive'; +import { isCancelled } from '../../utils/is-cancelled'; +import { style } from '../../utils/style'; +import { run } from '../../utils/run'; +import { context } from '../creation/context'; +import { create } from '../creation/create'; +import { series } from '../aggregate/series'; +import { raises } from '../exception/raises'; +import { print } from '../stdio/print'; +import { log } from '../stdio/log'; + +export interface PromptOptions { + /** + * A message to prompt. + */ + message?: string; + /** + * A timeout for the prompt. + */ + timeout?: number; + /** + * Default value. + * Will be triggered on empty responses, + * `timeout` expiration, and non-interactive contexts. + */ + default?: string | null; + /** + * Tests the validity of a response. + * Returns `true` for valid responses, + * `false` for non-valid ones. + */ + validate?: UnaryFn; +} + +/** + * Uses a context's stdio to prompt for a user response. + * The response will be prepended to the context arg array + * for `task`, when valid. + * @returns Task + */ +export function prompt(options: PromptOptions | Empty, task: Task): Task.Async { + return create(async (ctx) => { + const opts = shallow( + { + message: 'Continue:', + timeout: -1, + default: null as string | null, + validate: () => true + }, + options || undefined + ); + + const message = getBadge('prompt') + ` ${opts.message} `; + if (!isInteractive(ctx)) { + return TypeGuard.isString(opts.default) && opts.validate(opts.default) + ? series( + print(message), + log( + 'info', + 'Non-interactive default:', + style(opts.default, { bold: true }) + ), + context( + (ctx) => ({ ...ctx, args: [opts.default || '', ...ctx.args] }), + task + ) + ) + : series( + print(message), + raises(Error(`Must provide a default for non-interactive contexts`)) + ); + } + + const readline = createInterface({ + input: ctx.stdio[0], + output: ctx.stdio[1] + }); + + let timeout: null | NodeJS.Timeout = null; + const response = await Promise.race([ + new Promise((resolve) => { + ctx.cancellation.finally(() => resolve(null)); + timeout = + opts.timeout < 0 + ? null + : setTimeout(() => resolve(null), opts.timeout); + }), + into(null, function read() { + return new Promise((resolve, reject) => { + readline.question(message, (res) => { + let valid = false; + let error: [Error] | null = null; + const response = + !res && TypeGuard.isString(opts.default) ? opts.default : res; + try { + valid = opts.validate(response); + } catch (err) { + error = [err]; + } + isCancelled(ctx).then( + async (cancelled) => { + if (cancelled) return resolve(null); + if (valid) return resolve(response); + await run( + series( + error ? log('trace', error[0]) : null, + log( + 'error', + error ? stringifyError(error[0]) : 'Invalid response' + ) + ), + ctx + ); + if (await isCancelled(ctx)) return resolve(null); + return resolve(read()); + }, + (err) => reject(err) + ); + }); + }); + }) + ]); + + readline.close(); + if (timeout) clearTimeout(timeout); + if (response === null) ctx.stdio[1].write('\n'); + if (await isCancelled(ctx)) return; + + // Explicit response by user + if (response !== null) { + return context( + (ctx) => ({ ...ctx, args: [response, ...ctx.args] }), + task + ); + } + + // No response and timeout triggered with a default available + if (TypeGuard.isString(opts.default) && opts.validate(opts.default)) { + return series( + log('info', 'Timeout default:', style(opts.default, { bold: true })), + context( + (ctx) => ({ ...ctx, args: [opts.default || '', ...ctx.args] }), + task + ) + ); + } + // No response and timeout triggered without a default available + throw Error(`Timeout: ${opts.timeout}ms`); + }); +} diff --git a/src/tasks/stdio/select.ts b/src/tasks/stdio/select.ts index 007491d..b8d70d7 100644 --- a/src/tasks/stdio/select.ts +++ b/src/tasks/stdio/select.ts @@ -129,7 +129,7 @@ export function select( ); } // No response and no timeout triggered - if (!didTimeout) return raises(Error(`User cancellation`)); + if (!didTimeout) throw Error(`User cancellation`); // No response and timeout triggered with a default selection available if (fallback >= 0 && opts.default) { return series( @@ -142,6 +142,6 @@ export function select( ); } // No response and timeout triggered without a default selection available - return raises(Error(`Select timeout: ${opts.timeout}ms`)); + throw Error(`Select timeout: ${opts.timeout}ms`); }); }