620 lines
19 KiB
JavaScript
620 lines
19 KiB
JavaScript
/**
|
|
* @clack/prompts wrapper for BMAD CLI
|
|
*
|
|
* This module provides a unified interface for CLI prompts using @clack/prompts.
|
|
* It replaces Inquirer.js to fix Windows arrow key navigation issues (libuv #852).
|
|
*
|
|
* @module prompts
|
|
*/
|
|
|
|
let _clack = null;
|
|
let _clackCore = null;
|
|
let _picocolors = null;
|
|
|
|
/**
|
|
* Lazy-load @clack/prompts (ESM module)
|
|
* @returns {Promise<Object>} The clack prompts module
|
|
*/
|
|
async function getClack() {
|
|
if (!_clack) {
|
|
_clack = await import('@clack/prompts');
|
|
}
|
|
return _clack;
|
|
}
|
|
|
|
/**
|
|
* Lazy-load @clack/core (ESM module)
|
|
* @returns {Promise<Object>} The clack core module
|
|
*/
|
|
async function getClackCore() {
|
|
if (!_clackCore) {
|
|
_clackCore = await import('@clack/core');
|
|
}
|
|
return _clackCore;
|
|
}
|
|
|
|
/**
|
|
* Lazy-load picocolors
|
|
* @returns {Promise<Object>} The picocolors module
|
|
*/
|
|
async function getPicocolors() {
|
|
if (!_picocolors) {
|
|
_picocolors = (await import('picocolors')).default;
|
|
}
|
|
return _picocolors;
|
|
}
|
|
|
|
/**
|
|
* Handle user cancellation gracefully
|
|
* @param {any} value - The value to check
|
|
* @param {string} [message='Operation cancelled'] - Message to display
|
|
* @returns {boolean} True if cancelled
|
|
*/
|
|
async function handleCancel(value, message = 'Operation cancelled') {
|
|
const clack = await getClack();
|
|
if (clack.isCancel(value)) {
|
|
clack.cancel(message);
|
|
process.exit(0);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Display intro message
|
|
* @param {string} message - The intro message
|
|
*/
|
|
async function intro(message) {
|
|
const clack = await getClack();
|
|
clack.intro(message);
|
|
}
|
|
|
|
/**
|
|
* Display outro message
|
|
* @param {string} message - The outro message
|
|
*/
|
|
async function outro(message) {
|
|
const clack = await getClack();
|
|
clack.outro(message);
|
|
}
|
|
|
|
/**
|
|
* Display a note/info box
|
|
* @param {string} message - The note content
|
|
* @param {string} [title] - Optional title
|
|
*/
|
|
async function note(message, title) {
|
|
const clack = await getClack();
|
|
clack.note(message, title);
|
|
}
|
|
|
|
/**
|
|
* Display a spinner for async operations
|
|
* @returns {Object} Spinner controller with start, stop, message methods
|
|
*/
|
|
async function spinner() {
|
|
const clack = await getClack();
|
|
return clack.spinner();
|
|
}
|
|
|
|
/**
|
|
* Single-select prompt (replaces Inquirer 'list' type)
|
|
* @param {Object} options - Prompt options
|
|
* @param {string} options.message - The question to ask
|
|
* @param {Array} options.choices - Array of choices [{name, value, hint?}]
|
|
* @param {any} [options.default] - Default selected value
|
|
* @returns {Promise<any>} Selected value
|
|
*/
|
|
async function select(options) {
|
|
const clack = await getClack();
|
|
|
|
// Convert Inquirer-style choices to clack format
|
|
// Handle both object choices {name, value, hint} and primitive choices (string/number)
|
|
const clackOptions = options.choices
|
|
.filter((c) => c.type !== 'separator') // Skip separators for now
|
|
.map((choice) => {
|
|
if (typeof choice === 'string' || typeof choice === 'number') {
|
|
return { value: choice, label: String(choice) };
|
|
}
|
|
return {
|
|
value: choice.value === undefined ? choice.name : choice.value,
|
|
label: choice.name || choice.label || String(choice.value),
|
|
hint: choice.hint || choice.description,
|
|
};
|
|
});
|
|
|
|
// Find initial value
|
|
let initialValue;
|
|
if (options.default !== undefined) {
|
|
initialValue = options.default;
|
|
}
|
|
|
|
const result = await clack.select({
|
|
message: options.message,
|
|
options: clackOptions,
|
|
initialValue,
|
|
});
|
|
|
|
await handleCancel(result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Multi-select prompt (replaces Inquirer 'checkbox' type)
|
|
* @param {Object} options - Prompt options
|
|
* @param {string} options.message - The question to ask
|
|
* @param {Array} options.choices - Array of choices [{name, value, checked?, hint?}]
|
|
* @param {boolean} [options.required=false] - Whether at least one must be selected
|
|
* @returns {Promise<Array>} Array of selected values
|
|
*/
|
|
async function multiselect(options) {
|
|
const clack = await getClack();
|
|
|
|
// Support both clack-native (options) and Inquirer-style (choices) APIs
|
|
let clackOptions;
|
|
let initialValues;
|
|
|
|
if (options.options) {
|
|
// Native clack format: options with label/value
|
|
clackOptions = options.options;
|
|
initialValues = options.initialValues || [];
|
|
} else {
|
|
// Convert Inquirer-style choices to clack format
|
|
// Handle both object choices {name, value, hint} and primitive choices (string/number)
|
|
clackOptions = options.choices
|
|
.filter((c) => c.type !== 'separator') // Skip separators
|
|
.map((choice) => {
|
|
if (typeof choice === 'string' || typeof choice === 'number') {
|
|
return { value: choice, label: String(choice) };
|
|
}
|
|
return {
|
|
value: choice.value === undefined ? choice.name : choice.value,
|
|
label: choice.name || choice.label || String(choice.value),
|
|
hint: choice.hint || choice.description,
|
|
};
|
|
});
|
|
|
|
// Find initial values (pre-checked items)
|
|
initialValues = options.choices
|
|
.filter((c) => c.checked && c.type !== 'separator')
|
|
.map((c) => (c.value === undefined ? c.name : c.value));
|
|
}
|
|
|
|
const result = await clack.multiselect({
|
|
message: options.message,
|
|
options: clackOptions,
|
|
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
|
required: options.required || false,
|
|
});
|
|
|
|
await handleCancel(result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Grouped multi-select prompt for categorized options
|
|
* @param {Object} options - Prompt options
|
|
* @param {string} options.message - The question to ask
|
|
* @param {Object} options.options - Object mapping group names to arrays of choices
|
|
* @param {Array} [options.initialValues] - Array of initially selected values
|
|
* @param {boolean} [options.required=false] - Whether at least one must be selected
|
|
* @param {boolean} [options.selectableGroups=false] - Whether groups can be selected as a whole
|
|
* @returns {Promise<Array>} Array of selected values
|
|
*/
|
|
async function groupMultiselect(options) {
|
|
const clack = await getClack();
|
|
|
|
const result = await clack.groupMultiselect({
|
|
message: options.message,
|
|
options: options.options,
|
|
initialValues: options.initialValues,
|
|
required: options.required || false,
|
|
selectableGroups: options.selectableGroups || false,
|
|
});
|
|
|
|
await handleCancel(result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Default filter function for autocomplete - case-insensitive label matching
|
|
* @param {string} search - Search string
|
|
* @param {Object} option - Option object with label
|
|
* @returns {boolean} Whether the option matches
|
|
*/
|
|
function defaultAutocompleteFilter(search, option) {
|
|
const label = option.label ?? String(option.value ?? '');
|
|
return label.toLowerCase().includes(search.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Autocomplete multi-select prompt with type-ahead filtering
|
|
* Custom implementation that always shows "Space/Tab:" in the hint
|
|
* @param {Object} options - Prompt options
|
|
* @param {string} options.message - The question to ask
|
|
* @param {Array} options.options - Array of choices [{label, value, hint?}]
|
|
* @param {string} [options.placeholder] - Placeholder text for search input
|
|
* @param {Array} [options.initialValues] - Array of initially selected values
|
|
* @param {boolean} [options.required=false] - Whether at least one must be selected
|
|
* @param {number} [options.maxItems=5] - Maximum visible items in scrollable list
|
|
* @param {Function} [options.filter] - Custom filter function (search, option) => boolean
|
|
* @returns {Promise<Array>} Array of selected values
|
|
*/
|
|
async function autocompleteMultiselect(options) {
|
|
const core = await getClackCore();
|
|
const clack = await getClack();
|
|
const color = await getPicocolors();
|
|
|
|
const filterFn = options.filter ?? defaultAutocompleteFilter;
|
|
|
|
const prompt = new core.AutocompletePrompt({
|
|
options: options.options,
|
|
multiple: true,
|
|
filter: filterFn,
|
|
validate: () => {
|
|
if (options.required && prompt.selectedValues.length === 0) {
|
|
return 'Please select at least one item';
|
|
}
|
|
},
|
|
initialValue: options.initialValues,
|
|
render() {
|
|
const barColor = this.state === 'error' ? color.yellow : color.cyan;
|
|
const bar = barColor(clack.S_BAR);
|
|
const barEnd = barColor(clack.S_BAR_END);
|
|
|
|
const title = `${color.gray(clack.S_BAR)}\n${clack.symbol(this.state)} ${options.message}\n`;
|
|
|
|
const userInput = this.userInput;
|
|
const placeholder = options.placeholder || 'Type to search...';
|
|
const hasPlaceholder = userInput === '' && placeholder !== undefined;
|
|
|
|
// Show placeholder or user input with cursor
|
|
const searchDisplay =
|
|
this.isNavigating || hasPlaceholder ? color.dim(hasPlaceholder ? placeholder : userInput) : this.userInputWithCursor;
|
|
|
|
const allOptions = this.options;
|
|
const matchCount =
|
|
this.filteredOptions.length === allOptions.length
|
|
? ''
|
|
: color.dim(` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`);
|
|
|
|
// Render option with checkbox
|
|
const renderOption = (opt, isHighlighted) => {
|
|
const isSelected = this.selectedValues.includes(opt.value);
|
|
const label = opt.label ?? String(opt.value ?? '');
|
|
const hintText = opt.hint && opt.value === this.focusedValue ? color.dim(` (${opt.hint})`) : '';
|
|
const checkbox = isSelected ? color.green(clack.S_CHECKBOX_SELECTED) : color.dim(clack.S_CHECKBOX_INACTIVE);
|
|
return isHighlighted ? `${checkbox} ${label}${hintText}` : `${checkbox} ${color.dim(label)}`;
|
|
};
|
|
|
|
switch (this.state) {
|
|
case 'submit': {
|
|
return `${title}${color.gray(clack.S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`;
|
|
}
|
|
|
|
case 'cancel': {
|
|
return `${title}${color.gray(clack.S_BAR)} ${color.strikethrough(color.dim(userInput))}`;
|
|
}
|
|
|
|
default: {
|
|
// Always show "SPACE:" regardless of isNavigating state
|
|
const hints = [`${color.dim('↑/↓')} to navigate`, `${color.dim('TAB/SPACE:')} select`, `${color.dim('ENTER:')} confirm`];
|
|
|
|
const noMatchesLine = this.filteredOptions.length === 0 && userInput ? [`${bar} ${color.yellow('No matches found')}`] : [];
|
|
|
|
const errorLine = this.state === 'error' ? [`${bar} ${color.yellow(this.error)}`] : [];
|
|
|
|
const headerLines = [...`${title}${bar}`.split('\n'), `${bar} ${searchDisplay}${matchCount}`, ...noMatchesLine, ...errorLine];
|
|
|
|
const footerLines = [`${bar} ${color.dim(hints.join(' • '))}`, `${barEnd}`];
|
|
|
|
const optionLines = clack.limitOptions({
|
|
cursor: this.cursor,
|
|
options: this.filteredOptions,
|
|
style: renderOption,
|
|
maxItems: options.maxItems || 5,
|
|
output: options.output,
|
|
rowPadding: headerLines.length + footerLines.length,
|
|
});
|
|
|
|
return [...headerLines, ...optionLines.map((line) => `${bar} ${line}`), ...footerLines].join('\n');
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
const result = await prompt.prompt();
|
|
await handleCancel(result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Confirm prompt (replaces Inquirer 'confirm' type)
|
|
* @param {Object} options - Prompt options
|
|
* @param {string} options.message - The question to ask
|
|
* @param {boolean} [options.default=true] - Default value
|
|
* @returns {Promise<boolean>} User's answer
|
|
*/
|
|
async function confirm(options) {
|
|
const clack = await getClack();
|
|
|
|
const result = await clack.confirm({
|
|
message: options.message,
|
|
initialValue: options.default === undefined ? true : options.default,
|
|
});
|
|
|
|
await handleCancel(result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Text input prompt with Tab-to-fill-placeholder support (replaces Inquirer 'input' type)
|
|
*
|
|
* This custom implementation restores the Tab-to-fill-placeholder behavior that was
|
|
* intentionally removed in @clack/prompts v1.0.0 (placeholder became purely visual).
|
|
* Uses @clack/core's TextPrompt primitive with custom key handling.
|
|
*
|
|
* @param {Object} options - Prompt options
|
|
* @param {string} options.message - The question to ask
|
|
* @param {string} [options.default] - Default value
|
|
* @param {string} [options.placeholder] - Placeholder text (defaults to options.default if not provided)
|
|
* @param {Function} [options.validate] - Validation function
|
|
* @returns {Promise<string>} User's input
|
|
*/
|
|
async function text(options) {
|
|
const core = await getClackCore();
|
|
const color = await getPicocolors();
|
|
|
|
// Use default as placeholder if placeholder not explicitly provided
|
|
// This shows the default value as grayed-out hint text
|
|
const placeholder = options.placeholder === undefined ? options.default : options.placeholder;
|
|
const defaultValue = options.default;
|
|
|
|
const prompt = new core.TextPrompt({
|
|
defaultValue,
|
|
validate: options.validate,
|
|
render() {
|
|
const title = `${color.gray('◆')} ${options.message}`;
|
|
|
|
// Show placeholder as dim text when input is empty
|
|
let valueDisplay;
|
|
if (this.state === 'error') {
|
|
valueDisplay = color.yellow(this.userInputWithCursor);
|
|
} else if (this.userInput) {
|
|
valueDisplay = this.userInputWithCursor;
|
|
} else if (placeholder) {
|
|
// Show placeholder with cursor indicator when empty
|
|
valueDisplay = `${color.inverse(color.hidden('_'))}${color.dim(placeholder)}`;
|
|
} else {
|
|
valueDisplay = color.inverse(color.hidden('_'));
|
|
}
|
|
|
|
const bar = color.gray('│');
|
|
|
|
// Handle different states
|
|
if (this.state === 'submit') {
|
|
return `${color.gray('◇')} ${options.message}\n${bar} ${color.dim(this.value || defaultValue || '')}`;
|
|
}
|
|
|
|
if (this.state === 'cancel') {
|
|
return `${color.gray('◇')} ${options.message}\n${bar} ${color.strikethrough(color.dim(this.userInput || ''))}`;
|
|
}
|
|
|
|
if (this.state === 'error') {
|
|
return `${color.yellow('▲')} ${options.message}\n${bar} ${valueDisplay}\n${color.yellow('│')} ${color.yellow(this.error)}`;
|
|
}
|
|
|
|
return `${title}\n${bar} ${valueDisplay}\n${bar}`;
|
|
},
|
|
});
|
|
|
|
// Add Tab key handler to fill placeholder into input
|
|
prompt.on('key', (char) => {
|
|
if (char === '\t' && placeholder && !prompt.userInput) {
|
|
// Use _setUserInput with write=true to populate the readline and update internal state
|
|
prompt._setUserInput(placeholder, true);
|
|
}
|
|
});
|
|
|
|
const result = await prompt.prompt();
|
|
await handleCancel(result);
|
|
|
|
// TextPrompt's finalize handler already applies defaultValue for empty input
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Password input prompt (replaces Inquirer 'password' type)
|
|
* @param {Object} options - Prompt options
|
|
* @param {string} options.message - The question to ask
|
|
* @param {Function} [options.validate] - Validation function
|
|
* @returns {Promise<string>} User's input
|
|
*/
|
|
async function password(options) {
|
|
const clack = await getClack();
|
|
|
|
const result = await clack.password({
|
|
message: options.message,
|
|
validate: options.validate,
|
|
});
|
|
|
|
await handleCancel(result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Group multiple prompts together
|
|
* @param {Object} prompts - Object of prompt functions
|
|
* @param {Object} [options] - Group options
|
|
* @returns {Promise<Object>} Object with all answers
|
|
*/
|
|
async function group(prompts, options = {}) {
|
|
const clack = await getClack();
|
|
|
|
const result = await clack.group(prompts, {
|
|
onCancel: () => {
|
|
clack.cancel('Operation cancelled');
|
|
process.exit(0);
|
|
},
|
|
...options,
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Run tasks with spinner feedback
|
|
* @param {Array} tasks - Array of task objects [{title, task, enabled?}]
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function tasks(taskList) {
|
|
const clack = await getClack();
|
|
await clack.tasks(taskList);
|
|
}
|
|
|
|
/**
|
|
* Log messages with styling
|
|
*/
|
|
const log = {
|
|
async info(message) {
|
|
const clack = await getClack();
|
|
clack.log.info(message);
|
|
},
|
|
async success(message) {
|
|
const clack = await getClack();
|
|
clack.log.success(message);
|
|
},
|
|
async warn(message) {
|
|
const clack = await getClack();
|
|
clack.log.warn(message);
|
|
},
|
|
async error(message) {
|
|
const clack = await getClack();
|
|
clack.log.error(message);
|
|
},
|
|
async message(message) {
|
|
const clack = await getClack();
|
|
clack.log.message(message);
|
|
},
|
|
async step(message) {
|
|
const clack = await getClack();
|
|
clack.log.step(message);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Execute an array of Inquirer-style questions using @clack/prompts
|
|
* This provides compatibility with dynamic question arrays
|
|
* @param {Array} questions - Array of Inquirer-style question objects
|
|
* @returns {Promise<Object>} Object with answers keyed by question name
|
|
*/
|
|
async function prompt(questions) {
|
|
const answers = {};
|
|
|
|
for (const question of questions) {
|
|
const { type, name, message, choices, default: defaultValue, validate, when } = question;
|
|
|
|
// Handle conditional questions via 'when' property
|
|
if (when !== undefined) {
|
|
const shouldAsk = typeof when === 'function' ? await when(answers) : when;
|
|
if (!shouldAsk) continue;
|
|
}
|
|
|
|
let answer;
|
|
|
|
switch (type) {
|
|
case 'input': {
|
|
// Note: @clack/prompts doesn't support async validation, so validate must be sync
|
|
answer = await text({
|
|
message,
|
|
default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue,
|
|
validate: validate
|
|
? (val) => {
|
|
const result = validate(val, answers);
|
|
if (result instanceof Promise) {
|
|
throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.');
|
|
}
|
|
return result === true ? undefined : result;
|
|
}
|
|
: undefined,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'confirm': {
|
|
answer = await confirm({
|
|
message,
|
|
default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'list': {
|
|
answer = await select({
|
|
message,
|
|
choices: choices || [],
|
|
default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'checkbox': {
|
|
answer = await multiselect({
|
|
message,
|
|
choices: choices || [],
|
|
required: false,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'password': {
|
|
// Note: @clack/prompts doesn't support async validation, so validate must be sync
|
|
answer = await password({
|
|
message,
|
|
validate: validate
|
|
? (val) => {
|
|
const result = validate(val, answers);
|
|
if (result instanceof Promise) {
|
|
throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.');
|
|
}
|
|
return result === true ? undefined : result;
|
|
}
|
|
: undefined,
|
|
});
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
// Default to text input for unknown types
|
|
answer = await text({
|
|
message,
|
|
default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue,
|
|
});
|
|
}
|
|
}
|
|
|
|
answers[name] = answer;
|
|
}
|
|
|
|
return answers;
|
|
}
|
|
|
|
module.exports = {
|
|
getClack,
|
|
handleCancel,
|
|
intro,
|
|
outro,
|
|
note,
|
|
spinner,
|
|
select,
|
|
multiselect,
|
|
groupMultiselect,
|
|
autocompleteMultiselect,
|
|
confirm,
|
|
text,
|
|
password,
|
|
group,
|
|
tasks,
|
|
log,
|
|
prompt,
|
|
};
|