Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
112 changes: 91 additions & 21 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | undefined> {
// 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
*/
Expand All @@ -152,15 +193,9 @@ export async function initCommand(options: initOptions): Promise<void> {
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())) {
Expand Down Expand Up @@ -197,24 +232,56 @@ export async function initCommand(options: initOptions): Promise<void> {

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 <name>') +
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
Expand Down Expand Up @@ -265,6 +332,9 @@ export async function initCommand(options: initOptions): Promise<void> {
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:'));
Expand Down
44 changes: 44 additions & 0 deletions src/utils/prompts.test.ts
Original file line number Diff line number Diff line change
@@ -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)');
});
});
});
156 changes: 156 additions & 0 deletions src/utils/prompts.ts
Original file line number Diff line number Diff line change
@@ -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<SelectTemplateResult> {
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<boolean> {
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<string | null> {
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;
}
}