Skip to content

Commit

Permalink
feat(tasks): add prompt task
Browse files Browse the repository at this point in the history
  • Loading branch information
rafamel committed Apr 9, 2021
1 parent 70f5714 commit 1404baf
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 101 deletions.
125 changes: 26 additions & 99 deletions src/tasks/stdio/confirm.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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<null>((resolve) => {
ctx.cancellation.finally(() => resolve(null));
timeout =
opts.timeout < 0
? null
: setTimeout(() => resolve(null), opts.timeout);
}),
into(null, function read() {
return new Promise<boolean | null>((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`));
});
}
1 change: 1 addition & 0 deletions src/tasks/stdio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
157 changes: 157 additions & 0 deletions src/tasks/stdio/prompt.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>;
}

/**
* 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<null>((resolve) => {
ctx.cancellation.finally(() => resolve(null));
timeout =
opts.timeout < 0
? null
: setTimeout(() => resolve(null), opts.timeout);
}),
into(null, function read() {
return new Promise<string | null>((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`);
});
}
4 changes: 2 additions & 2 deletions src/tasks/stdio/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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`);
});
}

0 comments on commit 1404baf

Please sign in to comment.