diff --git a/package.json b/package.json index ff5f9d3..7ab09fe 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "chalk": "^5.3.0", "commander": "^12.0.0", + "enquirer": "^2.4.1", "fs-extra": "^11.2.0", "glob": "^10.3.10", "handlebars": "^4.7.8", diff --git a/src/commands/init.ts b/src/commands/init.ts index cabd267..8922f67 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -4,6 +4,7 @@ import path from 'path'; import { ProjectDetector } from '../detectors/project-detector.js'; import { TemplateGenerator } from '../templates/template-generator.js'; import { isValidTemplateId, getTemplateById, getAvailableTemplates, listTemplates } from './templates.js'; +import { selectTemplateInteractive, isInteractiveTerminal } from '../utils/prompts.js'; import type { ErrnoException } from '../types/index.js'; /** @@ -40,11 +41,11 @@ export type initOptions = { * Custom error class for init command errors */ export class InitError extends Error { - public readonly code: 'INVALID_TEMPLATE' | 'PERMISSION_DENIED' | 'EXISTING_FILES' | 'EMPTY_OUTPUT_DIR'; + public readonly code: 'INVALID_TEMPLATE' | 'PERMISSION_DENIED' | 'EXISTING_FILES' | 'EMPTY_OUTPUT_DIR' | 'PROMPT_CANCELLED'; constructor( message: string, - code: 'INVALID_TEMPLATE' | 'PERMISSION_DENIED' | 'EXISTING_FILES' | 'EMPTY_OUTPUT_DIR' + code: 'INVALID_TEMPLATE' | 'PERMISSION_DENIED' | 'EXISTING_FILES' | 'EMPTY_OUTPUT_DIR' | 'PROMPT_CANCELLED' ) { super(message); this.name = 'InitError'; @@ -137,6 +138,46 @@ function getTemplateDisplayName(templateId?: string): string { return template?.name || templateId; } +/** + * Get template via interactive selection when --template is not provided + * Returns undefined if user cancels or terminal is not interactive + */ +async function getTemplateInteractively( + detectedType?: string +): Promise { + // Only show interactive prompt if terminal supports it + if (!isInteractiveTerminal()) { + return undefined; + } + + console.log(); + console.log(chalk.cyan('┌─────────────────────────────────────────────────────────────┐')); + console.log(chalk.cyan('│') + chalk.white.bold(' Select a template for your project') + ' '.repeat(18) + chalk.cyan('│')); + console.log(chalk.cyan('└─────────────────────────────────────────────────────────────┘')); + console.log(); + + const templates = getAvailableTemplates(); + + // Try to detect project type if not provided + let recommendedTemplate: string | undefined; + if (detectedType && isValidTemplateId(detectedType)) { + recommendedTemplate = detectedType; + } + + const result = await selectTemplateInteractive({ + templates, + message: 'Which template would you like to use?', + defaultTemplate: recommendedTemplate || 'default', + }); + + if (result.cancelled) { + console.log(chalk.yellow('\n✗ Template selection cancelled')); + return undefined; + } + + return result.templateId; +} + /** * Main init command implementation */ @@ -152,15 +193,9 @@ export async function initCommand(options: initOptions): Promise { const outputDir = path.resolve(options.output); try { - // Validate template - const templateId = validateTemplate(options.template); - const templateName = getTemplateDisplayName(templateId); - - // Show selected template - console.log(chalk.gray(`Template: ${chalk.white(templateName)}`)); - if (options.dryRun) { - console.log(chalk.yellow('Mode: Dry run (no files will be written)\n')); - } + // Validate template if provided + let templateId = validateTemplate(options.template); + let templateName = getTemplateDisplayName(templateId); // Check for empty output directory if (outputDir !== path.resolve(process.cwd())) { @@ -197,24 +232,56 @@ export async function initCommand(options: initOptions): Promise { let projectType = templateId; - // Auto-detect project type if not specified and --force is not used + // Auto-detect project type first (before interactive selection) + let detectedType: string | undefined; if (!options.skipDetect && !projectType && !options.force) { const detector = new ProjectDetector(); const detection = await detector.detect(); - projectType = detection.type; - console.log(chalk.gray(`Detected project type: ${chalk.white(projectType || 'unknown')}`)); - if (detection.framework) { - console.log(chalk.gray(`Framework: ${chalk.white(detection.framework)}`)); + detectedType = detection.type; + // Show detection info if applicable + if (!isInteractiveTerminal()) { + console.log(chalk.gray(`Detected project type: ${chalk.white(detectedType || 'unknown')}`)); + } + } + + // If no template specified and not in force mode, show interactive selection + if (!projectType && !options.force && !options.template) { + // If we detected a type, show the prompt with recommendation + if (isInteractiveTerminal()) { + const interactiveTemplate = await getTemplateInteractively(detectedType); + + if (interactiveTemplate) { + projectType = interactiveTemplate; + templateName = getTemplateDisplayName(projectType); + } else { + // User cancelled - exit gracefully + console.log(chalk.gray('\nNo template selected. Run ') + + chalk.cyan('ao init --template ') + + chalk.gray(' to specify a template.')); + return; + } + } else if (detectedType && isValidTemplateId(detectedType)) { + // Non-interactive: use detected type if valid + projectType = detectedType; + templateName = getTemplateDisplayName(projectType); } + } + + // Show template selection + console.log(chalk.gray(`Template: ${chalk.white(templateName)}`)); + if (options.dryRun) { + console.log(chalk.yellow('Mode: Dry run (no files will be written)\n')); + } + + // Handle force mode + if (options.force && !projectType) { + projectType = 'default'; + templateName = getTemplateDisplayName(projectType); + console.log(chalk.yellow('Note: --force bypasses auto-detection, using default template')); console.log(); } else if (options.force && projectType) { console.log(chalk.gray(`Using specified template: ${chalk.white(projectType)}`)); console.log(); - } else if (options.force && !options.template) { - // With --force, use 'default' template to bypass detection - projectType = 'default'; - console.log(chalk.yellow('Note: --force bypasses auto-detection, using default template')); - console.log(); } // Generate templates @@ -265,6 +332,9 @@ export async function initCommand(options: initOptions): Promise { console.error(chalk.red('✗ Empty output directory:')); console.error(chalk.red(` ${error.message}`)); break; + case 'PROMPT_CANCELLED': + console.error(chalk.yellow('✗ Operation cancelled')); + break; } } else { console.error(chalk.red('✗ Error initializing AO workflows:')); diff --git a/src/utils/prompts.test.ts b/src/utils/prompts.test.ts new file mode 100644 index 0000000..9698d0c --- /dev/null +++ b/src/utils/prompts.test.ts @@ -0,0 +1,44 @@ +import { isInteractiveTerminal, formatTemplateForSelection } from './prompts.js'; +import type { TemplateInfo } from '../commands/templates.js'; + +describe('prompts utilities', () => { + describe('isInteractiveTerminal', () => { + it('should return false when stdin is not a TTY', () => { + // In Jest environment, stdin is typically not a TTY + expect(isInteractiveTerminal()).toBe(false); + }); + }); + + describe('formatTemplateForSelection', () => { + it('should format template with name, description, and default badge', () => { + const template: TemplateInfo = { + id: 'typescript', + name: 'TypeScript', + description: 'TypeScript-optimized workflows', + suitableFor: ['TypeScript projects'], + isDefault: false, + }; + + const result = formatTemplateForSelection(template); + + expect(result).toContain('TypeScript'); + expect(result).toContain('TypeScript-optimized workflows'); + expect(result).not.toContain('(default)'); + }); + + it('should include default badge for default template', () => { + const template: TemplateInfo = { + id: 'default', + name: 'Default', + description: 'Standard AO workflow', + suitableFor: ['Any project'], + isDefault: true, + }; + + const result = formatTemplateForSelection(template); + + expect(result).toContain('Default'); + expect(result).toContain('(default)'); + }); + }); +}); diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts new file mode 100644 index 0000000..e2aecbb --- /dev/null +++ b/src/utils/prompts.ts @@ -0,0 +1,156 @@ +import chalk from 'chalk'; +import Enquirer from 'enquirer'; +import type { TemplateInfo } from '../commands/templates.js'; + +/** + * Options for interactive template selection + */ +export interface SelectTemplateOptions { + /** Available templates to choose from */ + templates: TemplateInfo[]; + /** Message to display */ + message?: string; + /** Default template ID (if any) */ + defaultTemplate?: string; +} + +/** + * Result of template selection + */ +export interface SelectTemplateResult { + /** Selected template ID */ + templateId: string; + /** Whether the user cancelled */ + cancelled: boolean; +} + +/** + * Interactive template selection prompt + * Uses enquirer for arrow-key navigation and selection + */ +export async function selectTemplateInteractive( + options: SelectTemplateOptions +): Promise { + const { templates, message = 'Select a template:', defaultTemplate } = options; + + // Format choices for enquirer - include description in the message + const choices = templates.map((template) => ({ + name: template.id, + message: `${template.name}${template.isDefault ? ' (default)' : ''} — ${template.description}`, + hint: `Suitable for: ${template.suitableFor.join(', ')}`, + })); + + // Find default index + const defaultIndex = defaultTemplate + ? templates.findIndex((t) => t.id === defaultTemplate) + : templates.findIndex((t) => t.isDefault); + + try { + const response = await Enquirer.prompt<{ template: string }>([ + { + type: 'select', + name: 'template', + message, + choices, + initial: defaultIndex >= 0 ? defaultIndex : 0, + format: (value: string) => { + const template = templates.find((t) => t.id === value); + return template ? chalk.white(template.name) : value; + }, + }, + ]); + + return { + templateId: response.template, + cancelled: false, + }; + } catch (error) { + // Handle ctrl+c or other cancellations + if ((error as NodeJS.ErrnoException).code === 'ERR_CANCELED') { + return { + templateId: '', + cancelled: true, + }; + } + throw error; + } +} + +/** + * Check if the terminal supports interactive mode + * Returns false if stdin is not a TTY + */ +export function isInteractiveTerminal(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true; +} + +/** + * Format template for display in interactive selection + */ +export function formatTemplateForSelection(template: TemplateInfo): string { + const name = chalk.cyan(template.name); + const description = chalk.gray(template.description); + const defaultBadge = template.isDefault ? chalk.yellow(' (default)') : ''; + + return `${name}${defaultBadge} — ${description}`; +} + +/** + * Show a confirmation prompt + */ +export async function confirmAction( + message: string, + defaultValue: boolean = false +): Promise { + try { + const response = await Enquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message, + initial: defaultValue, + }, + ]); + + return response.confirmed; + } catch { + return false; + } +} + +/** + * Show an input prompt for a string value + */ +export async function promptForInput( + message: string, + options?: { + default?: string; + required?: boolean; + validate?: (value: string) => boolean | string; + } +): Promise { + try { + const response = await Enquirer.prompt<{ value: string }>([ + { + type: 'input', + name: 'value', + message, + initial: options?.default, + validate: (value: string) => { + if (options?.required && !value.trim()) { + return 'This field is required'; + } + if (options?.validate) { + const result = options.validate(value); + return result === true || typeof result === 'string' ? result : 'Invalid input'; + } + return true; + }, + }, + ]); + + return response.value; + } catch { + return null; + } +}