Skip to content

Commit

Permalink
fix(tasks): fix prompt block on windows
Browse files Browse the repository at this point in the history
  • Loading branch information
rafamel committed Apr 17, 2021
1 parent 5ffdda8 commit 38978d7
Show file tree
Hide file tree
Showing 2 changed files with 133 additions and 79 deletions.
204 changes: 129 additions & 75 deletions src/tasks/stdio/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Empty, TypeGuard, UnaryFn } from 'type-core';
import { Empty, TypeGuard, UnaryFn, VariadicFn } from 'type-core';
import { shallow } from 'merge-strategies';
import { createInterface } from 'readline';
import { into } from 'pipettes';
import { Task } from '../../definitions';
import { Context, Task } from '../../definitions';
import { getBadge } from '../../helpers/badges';
import { addPrefix } from '../../helpers/prefix';
import { stringifyError } from '../../helpers/stringify';
Expand All @@ -22,16 +21,16 @@ 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;
/**
* A timeout for the prompt.
*/
timeout?: number;
/**
* Tests the validity of a response.
* Returns `true` for valid responses,
Expand Down Expand Up @@ -79,80 +78,135 @@ export function prompt(options: PromptOptions | Empty, task: Task): Task.Async {
);
}

const readline = createInterface({
input: ctx.stdio[0],
output: ctx.stdio[1]
});
let response: null | string = null;
while (!(await isCancelled(ctx))) {
response = await line(
addPrefix(opts.message + ' ', null, 'print', ctx),
opts.timeout,
ctx
).then((res) => {
return res === '' && TypeGuard.isString(opts.default)
? opts.default
: res;
});

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(addPrefix(message, null, 'print', ctx), (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)
);
});
});
})
]);
/* Context was cancelled */
if (await isCancelled(ctx)) return;

/* Response is null. Context is not cancelled. Must be timeout. */
if (response === null) break;

/* Response is a string */
let valid = false;
let error: [Error] | null = null;
try {
valid = opts.validate(response);
} catch (err) {
error = [err];
}

readline.close();
if (timeout) clearTimeout(timeout);
if (response === null) ctx.stdio[1].write('\n');
if (valid && !error) {
break;
} else {
response = null;
await run(
series(
error ? log('trace', error[0]) : null,
log(
'error',
error
? style(stringifyError(error[0]), { bold: true })
: style('Invalid response', { bold: true })
)
),
ctx
);
}
}

/* Context was cancelled */
if (await isCancelled(ctx)) return;

// Explicit response by user
if (response !== null) {
return context(
(ctx) => ({ ...ctx, args: [response, ...ctx.args] }),
task
);
/* Response is null. Context is not cancelled. Must be timeout. */
if (response === null) {
if (TypeGuard.isString(opts.default) && opts.validate(opts.default)) {
await run(
log('info', 'Timeout default:', style(opts.default, { bold: true })),
ctx
);
response = opts.default;
} else {
throw Error(`Timeout: ${opts.timeout}ms`);
}
}

// 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
)
);
/* Response is valid */
const str = response;
return context(
(ctx) => ({
...ctx,
args: [str, ...ctx.args]
}),
task
);
});
}

async function line(
message: string,
timeout: number,
context: Context.Interactive
): Promise<string | null> {
const stdin = context.stdio[0];
const stdout = context.stdio[1];

const readline = createInterface({
input: stdin,
output: stdout,
terminal: stdout.isTTY
});
readline.setPrompt(message);
readline.prompt();

let timer: null | NodeJS.Timeout = null;
let listener: null | VariadicFn = null;
return new Promise<string | null>((resolve, reject) => {
function close(write: boolean): void {
readline.close();
if (timer) clearTimeout(timer);
if (listener) stdin.removeListener('keypress', listener);
if (write) stdout.write('\n');
}
// No response and timeout triggered without a default available
throw Error(`Timeout: ${opts.timeout}ms`);

readline.on('line', (res) => {
close(false);
return resolve(res.replace(/\r?\n$/, ''));
});
readline.on('error', (err) => {
close(true);
return reject(err);
});
readline.on('SIGINT', () => {
close(true);
return reject(Error(`User cancellation`));
});
listener = (_, item) => {
if (item && item.name === 'escape') {
close(true);
reject(Error(`User cancellation`));
}
};
stdin.on('keypress', listener);
context.cancellation.finally(() => {
close(true);
return resolve(null);
});
timer =
timeout < 0
? null
: setTimeout(() => {
close(true);
resolve(null);
}, timeout);
});
}
8 changes: 4 additions & 4 deletions src/tasks/stdio/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ export interface SelectOptions {
* A message to prompt.
*/
message?: string;
/**
* A timeout for the select.
*/
timeout?: number;
/**
* A default selection.
* Will be triggered on `timeout` expiration
* and non-interactive contexts.
*/
default?: string | null;
/**
* A timeout for the select.
*/
timeout?: number;
}

/**
Expand Down

0 comments on commit 38978d7

Please sign in to comment.