diff --git a/packages/inquirerer/__tests__/defaultFrom.test.ts b/packages/inquirerer/__tests__/defaultFrom.test.ts new file mode 100644 index 0000000..0d35221 --- /dev/null +++ b/packages/inquirerer/__tests__/defaultFrom.test.ts @@ -0,0 +1,554 @@ +import readline from 'readline'; +import { Readable, Transform, Writable } from 'stream'; +import { stripAnsi } from 'clean-ansi'; +import { Inquirerer, DefaultResolverRegistry } from '../src'; +import { Question } from '../src/question'; + +jest.mock('readline'); +jest.mock('child_process', () => ({ + execSync: jest.fn() +})); + +import { execSync } from 'child_process'; +const mockedExecSync = execSync as jest.MockedFunction; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +describe('Inquirerer - defaultFrom feature', () => { + let mockWrite: jest.Mock; + let mockInput: Readable; + let mockOutput: Writable; + let transformStream: Transform; + + let writeResults: string[]; + let transformResults: string[]; + + let inputQueue: Array<{ type: 'key' | 'read', value: string }> = []; + let currentInputIndex: number = 0; + + function setupReadlineMock() { + readline.createInterface = jest.fn().mockReturnValue({ + question: (questionText: string, cb: (input: string) => void) => { + // Process the queued inputs when question is called + const nextInput = inputQueue[currentInputIndex++]; + if (nextInput && nextInput.type === 'read') { + setTimeout(() => cb(nextInput.value), 350); // Simulate readline delay + } + }, + close: jest.fn(), + }); + } + + function enqueueInputResponse(input: { type: 'key' | 'read', value: string }) { + if (input.type === 'key') { + // Push key events directly to mockInput + // @ts-ignore + setTimeout(() => mockInput.push(input.value), 350); + } else { + // Queue readline responses to be handled by the readline mock + inputQueue.push(input); + } + } + + beforeEach(() => { + mockWrite = jest.fn(); + writeResults = []; + transformResults = []; + inputQueue = []; + currentInputIndex = 0; + + mockInput = new Readable({ + read(size) { } + }); + // @ts-ignore + mockInput.setRawMode = jest.fn(); // Mock TTY-specific method if needed + + mockOutput = new Writable({ + write: (chunk, encoding, callback) => { + const str = chunk.toString(); + writeResults.push(stripAnsi(str)); + mockWrite(str); + callback(); + } + }); + + // Create the transform stream to log and pass through data + transformStream = new Transform({ + transform(chunk, encoding, callback) { + const data = chunk.toString(); + transformResults.push(stripAnsi(data)); + this.push(chunk); // Pass the data through + callback(); + } + }); + + setupReadlineMock(); + mockInput.pipe(transformStream); + + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-11-23T15:30:45.123Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('git resolvers', () => { + it('should use git.user.name as default', async () => { + mockedExecSync.mockReturnValue('John Doe\n' as any); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true // Non-interactive mode to use defaults + }); + + const questions: Question[] = [ + { + name: 'authorName', + type: 'text', + defaultFrom: 'git.user.name' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ authorName: 'John Doe' }); + expect(mockedExecSync).toHaveBeenCalledWith( + 'git config --global user.name', + expect.any(Object) + ); + }); + + it('should use git.user.email as default', async () => { + mockedExecSync.mockReturnValue('john@example.com\n' as any); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'authorEmail', + type: 'text', + defaultFrom: 'git.user.email' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ authorEmail: 'john@example.com' }); + expect(mockedExecSync).toHaveBeenCalledWith( + 'git config --global user.email', + expect.any(Object) + ); + }); + + it('should fallback to static default when git config fails', async () => { + mockedExecSync.mockImplementation(() => { + throw new Error('Git not configured'); + }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'authorName', + type: 'text', + defaultFrom: 'git.user.name', + default: 'Anonymous' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ authorName: 'Anonymous' }); + }); + + it('should resolve multiple git fields', async () => { + mockedExecSync + .mockReturnValueOnce('Jane Smith\n' as any) + .mockReturnValueOnce('jane@example.com\n' as any); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'name', + type: 'text', + defaultFrom: 'git.user.name' + }, + { + name: 'email', + type: 'text', + defaultFrom: 'git.user.email' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ + name: 'Jane Smith', + email: 'jane@example.com' + }); + }); + }); + + describe('date resolvers', () => { + it('should use date.year as default', async () => { + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'year', + type: 'text', + defaultFrom: 'date.year' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ year: '2025' }); + }); + + it('should use date.iso as default', async () => { + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'date', + type: 'text', + defaultFrom: 'date.iso' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ date: '2025-11-23' }); + }); + + it('should resolve multiple date fields', async () => { + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'year', + type: 'text', + defaultFrom: 'date.year' + }, + { + name: 'month', + type: 'text', + defaultFrom: 'date.month' + }, + { + name: 'day', + type: 'text', + defaultFrom: 'date.day' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ + year: '2025', + month: '11', + day: '23' + }); + }); + }); + + describe('custom resolver registry', () => { + it('should use custom resolver registry', async () => { + const customRegistry = new DefaultResolverRegistry(); + customRegistry.register('custom.value', () => 'custom-result'); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true, + resolverRegistry: customRegistry + }); + + const questions: Question[] = [ + { + name: 'customField', + type: 'text', + defaultFrom: 'custom.value' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ customField: 'custom-result' }); + }); + + it('should use custom async resolver', async () => { + const customRegistry = new DefaultResolverRegistry(); + customRegistry.register('custom.async', async () => { + // Return a promise without setTimeout to avoid issues with fake timers + return Promise.resolve('async-result'); + }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true, + resolverRegistry: customRegistry + }); + + const questions: Question[] = [ + { + name: 'asyncField', + type: 'text', + defaultFrom: 'custom.async' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ asyncField: 'async-result' }); + }); + }); + + describe('priority and fallbacks', () => { + it('should prioritize argv over defaultFrom', async () => { + mockedExecSync.mockReturnValue('Git User\n' as any); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'authorName', + type: 'text', + defaultFrom: 'git.user.name' + } + ]; + + const result = await prompter.prompt({ authorName: 'Override' }, questions); + + expect(result).toEqual({ authorName: 'Override' }); + // Git should not even be called since argv overrides + }); + + it('should use undefined when resolver returns undefined and no static default', async () => { + mockedExecSync.mockImplementation(() => { + throw new Error('Git not configured'); + }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'authorName', + type: 'text', + defaultFrom: 'git.user.name' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ authorName: undefined }); + }); + + it('should handle mixed defaultFrom and static defaults', async () => { + mockedExecSync.mockReturnValue('Jane Doe\n' as any); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'authorName', + type: 'text', + defaultFrom: 'git.user.name' + }, + { + name: 'license', + type: 'text', + default: 'MIT' // Static default only + }, + { + name: 'year', + type: 'text', + defaultFrom: 'date.year' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ + authorName: 'Jane Doe', + license: 'MIT', + year: '2025' + }); + }); + }); + + describe('question types with defaultFrom', () => { + it('should work with confirm type', async () => { + const customRegistry = new DefaultResolverRegistry(); + customRegistry.register('custom.bool', () => true); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true, // Non-interactive to avoid readline complexity + resolverRegistry: customRegistry + }); + + const questions: Question[] = [ + { + name: 'confirmed', + type: 'confirm', + defaultFrom: 'custom.bool' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ confirmed: true }); + }); + + it('should work with number type', async () => { + const customRegistry = new DefaultResolverRegistry(); + customRegistry.register('custom.number', () => 42); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true, + resolverRegistry: customRegistry + }); + + const questions: Question[] = [ + { + name: 'count', + type: 'number', + defaultFrom: 'custom.number' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ count: 42 }); + }); + }); + + describe('edge cases', () => { + it('should handle resolver that returns empty string', async () => { + const customRegistry = new DefaultResolverRegistry(); + customRegistry.register('custom.empty', () => ''); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true, + resolverRegistry: customRegistry + }); + + const questions: Question[] = [ + { + name: 'field', + type: 'text', + defaultFrom: 'custom.empty', + default: 'fallback' + } + ]; + + const result = await prompter.prompt({}, questions); + + // Empty string from resolver is treated as undefined, should use static default + expect(result).toEqual({ field: 'fallback' }); + }); + + it('should handle resolver that throws error gracefully', async () => { + const customRegistry = new DefaultResolverRegistry(); + customRegistry.register('custom.error', () => { + throw new Error('Resolver error'); + }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true, + resolverRegistry: customRegistry + }); + + const questions: Question[] = [ + { + name: 'field', + type: 'text', + defaultFrom: 'custom.error', + default: 'fallback' + } + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ field: 'fallback' }); + }); + + it('should not override when defaultFrom resolver fails and field is required', async () => { + mockedExecSync.mockImplementation(() => { + throw new Error('Git not configured'); + }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const questions: Question[] = [ + { + name: 'authorName', + type: 'text', + required: true, + defaultFrom: 'git.user.name' + } + ]; + + // Should throw because required field has no value + await expect(prompter.prompt({}, questions)).rejects.toThrow( + 'Missing required arguments' + ); + }); + }); +}); diff --git a/packages/inquirerer/__tests__/resolvers.test.ts b/packages/inquirerer/__tests__/resolvers.test.ts new file mode 100644 index 0000000..2ef5d11 --- /dev/null +++ b/packages/inquirerer/__tests__/resolvers.test.ts @@ -0,0 +1,424 @@ +import { + DefaultResolverRegistry, + globalResolverRegistry, + registerDefaultResolver, + resolveDefault, + getGitConfig +} from '../src/resolvers'; + +// Mock child_process.execSync for git config tests +jest.mock('child_process', () => ({ + execSync: jest.fn() +})); + +import { execSync } from 'child_process'; +const mockedExecSync = execSync as jest.MockedFunction; + +describe('DefaultResolverRegistry', () => { + let registry: DefaultResolverRegistry; + + beforeEach(() => { + registry = new DefaultResolverRegistry(); + jest.clearAllMocks(); + }); + + describe('register and resolve', () => { + it('should register and resolve a simple synchronous resolver', async () => { + registry.register('test.value', () => 'test-result'); + + const result = await registry.resolve('test.value'); + + expect(result).toBe('test-result'); + }); + + it('should register and resolve an async resolver', async () => { + registry.register('test.async', async () => { + return new Promise(resolve => setTimeout(() => resolve('async-result'), 10)); + }); + + const result = await registry.resolve('test.async'); + + expect(result).toBe('async-result'); + }); + + it('should return undefined for non-existent resolver', async () => { + const result = await registry.resolve('non.existent'); + + expect(result).toBeUndefined(); + }); + + it('should treat empty string as undefined', async () => { + registry.register('test.empty', () => ''); + + const result = await registry.resolve('test.empty'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when resolver throws error', async () => { + registry.register('test.error', () => { + throw new Error('Test error'); + }); + + const result = await registry.resolve('test.error'); + + expect(result).toBeUndefined(); + }); + + it('should handle resolver that returns null', async () => { + registry.register('test.null', () => null); + + const result = await registry.resolve('test.null'); + + expect(result).toBeNull(); + }); + + it('should handle resolver that returns false', async () => { + registry.register('test.false', () => false); + + const result = await registry.resolve('test.false'); + + expect(result).toBe(false); + }); + + it('should handle resolver that returns 0', async () => { + registry.register('test.zero', () => 0); + + const result = await registry.resolve('test.zero'); + + expect(result).toBe(0); + }); + }); + + describe('unregister', () => { + it('should unregister a resolver', async () => { + registry.register('test.value', () => 'test-result'); + registry.unregister('test.value'); + + const result = await registry.resolve('test.value'); + + expect(result).toBeUndefined(); + }); + }); + + describe('has', () => { + it('should return true for registered resolver', () => { + registry.register('test.value', () => 'test-result'); + + expect(registry.has('test.value')).toBe(true); + }); + + it('should return false for non-existent resolver', () => { + expect(registry.has('non.existent')).toBe(false); + }); + }); + + describe('keys', () => { + it('should return all registered keys', () => { + registry.register('test.one', () => 'one'); + registry.register('test.two', () => 'two'); + registry.register('test.three', () => 'three'); + + const keys = registry.keys(); + + expect(keys).toEqual(['test.one', 'test.two', 'test.three']); + }); + + it('should return empty array when no resolvers registered', () => { + const keys = registry.keys(); + + expect(keys).toEqual([]); + }); + }); + + describe('clone', () => { + it('should create a copy with all resolvers', async () => { + registry.register('test.value', () => 'test-result'); + + const cloned = registry.clone(); + const result = await cloned.resolve('test.value'); + + expect(result).toBe('test-result'); + }); + + it('should not affect original when modifying clone', async () => { + registry.register('test.value', () => 'original'); + + const cloned = registry.clone(); + cloned.register('test.value', () => 'modified'); + + const originalResult = await registry.resolve('test.value'); + const clonedResult = await cloned.resolve('test.value'); + + expect(originalResult).toBe('original'); + expect(clonedResult).toBe('modified'); + }); + }); +}); + +describe('Git Resolvers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getGitConfig', () => { + it('should return git config value when successful', () => { + mockedExecSync.mockReturnValue('John Doe\n' as any); + + const result = getGitConfig('user.name'); + + expect(result).toBe('John Doe'); + expect(mockedExecSync).toHaveBeenCalledWith( + 'git config --global user.name', + expect.objectContaining({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }) + ); + }); + + it('should trim whitespace from git config value', () => { + mockedExecSync.mockReturnValue(' test@example.com \n' as any); + + const result = getGitConfig('user.email'); + + expect(result).toBe('test@example.com'); + }); + + it('should return undefined when git config fails', () => { + mockedExecSync.mockImplementation(() => { + throw new Error('Git config not found'); + }); + + const result = getGitConfig('user.name'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when git config returns empty string', () => { + mockedExecSync.mockReturnValue('' as any); + + const result = getGitConfig('user.name'); + + expect(result).toBeUndefined(); + }); + }); + + describe('git.user.name resolver', () => { + it('should resolve git user name', async () => { + mockedExecSync.mockReturnValue('Jane Smith\n' as any); + + const result = await globalResolverRegistry.resolve('git.user.name'); + + expect(result).toBe('Jane Smith'); + }); + + it('should return undefined when git config fails', async () => { + mockedExecSync.mockImplementation(() => { + throw new Error('Git not configured'); + }); + + const result = await globalResolverRegistry.resolve('git.user.name'); + + expect(result).toBeUndefined(); + }); + }); + + describe('git.user.email resolver', () => { + it('should resolve git user email', async () => { + mockedExecSync.mockReturnValue('jane@example.com\n' as any); + + const result = await globalResolverRegistry.resolve('git.user.email'); + + expect(result).toBe('jane@example.com'); + }); + + it('should return undefined when git config fails', async () => { + mockedExecSync.mockImplementation(() => { + throw new Error('Git not configured'); + }); + + const result = await globalResolverRegistry.resolve('git.user.email'); + + expect(result).toBeUndefined(); + }); + }); +}); + +describe('Date Resolvers', () => { + beforeEach(() => { + // Mock Date to return consistent values + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-11-23T15:30:45.123Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('date.year', () => { + it('should resolve current year', async () => { + const result = await globalResolverRegistry.resolve('date.year'); + + expect(result).toBe('2025'); + }); + }); + + describe('date.month', () => { + it('should resolve current month with zero padding', async () => { + const result = await globalResolverRegistry.resolve('date.month'); + + expect(result).toBe('11'); + }); + + it('should zero-pad single digit months', async () => { + jest.setSystemTime(new Date('2025-03-15T12:00:00Z')); + + const result = await globalResolverRegistry.resolve('date.month'); + + expect(result).toBe('03'); + }); + }); + + describe('date.day', () => { + it('should resolve current day with zero padding', async () => { + const result = await globalResolverRegistry.resolve('date.day'); + + expect(result).toBe('23'); + }); + + it('should zero-pad single digit days', async () => { + jest.setSystemTime(new Date('2025-03-05T12:00:00Z')); + + const result = await globalResolverRegistry.resolve('date.day'); + + expect(result).toBe('05'); + }); + }); + + describe('date.now', () => { + it('should resolve current ISO timestamp', async () => { + const result = await globalResolverRegistry.resolve('date.now'); + + expect(result).toBe('2025-11-23T15:30:45.123Z'); + }); + }); + + describe('date.iso', () => { + it('should resolve current date in YYYY-MM-DD format', async () => { + const result = await globalResolverRegistry.resolve('date.iso'); + + expect(result).toBe('2025-11-23'); + }); + }); + + describe('date.timestamp', () => { + it('should resolve current timestamp in milliseconds', async () => { + const result = await globalResolverRegistry.resolve('date.timestamp'); + + // 2025-11-23T15:30:45.123Z corresponds to this timestamp + expect(result).toBe(String(new Date('2025-11-23T15:30:45.123Z').getTime())); + }); + }); +}); + +describe('Global Registry', () => { + it('should have git resolvers registered by default', () => { + expect(globalResolverRegistry.has('git.user.name')).toBe(true); + expect(globalResolverRegistry.has('git.user.email')).toBe(true); + }); + + it('should have date resolvers registered by default', () => { + expect(globalResolverRegistry.has('date.year')).toBe(true); + expect(globalResolverRegistry.has('date.month')).toBe(true); + expect(globalResolverRegistry.has('date.day')).toBe(true); + expect(globalResolverRegistry.has('date.now')).toBe(true); + expect(globalResolverRegistry.has('date.iso')).toBe(true); + expect(globalResolverRegistry.has('date.timestamp')).toBe(true); + }); +}); + +describe('Convenience Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset any custom resolvers by creating a fresh state + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-11-23T15:30:45.123Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('registerDefaultResolver', () => { + it('should register a resolver on the global registry', async () => { + registerDefaultResolver('custom.test', () => 'custom-value'); + + const result = await resolveDefault('custom.test'); + + expect(result).toBe('custom-value'); + }); + }); + + describe('resolveDefault', () => { + it('should resolve from the global registry', async () => { + const result = await resolveDefault('date.year'); + + expect(result).toBe('2025'); + }); + + it('should return undefined for non-existent resolver', async () => { + const result = await resolveDefault('non.existent'); + + expect(result).toBeUndefined(); + }); + }); +}); + +describe('Debug Mode', () => { + let originalDebug: string | undefined; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + originalDebug = process.env.DEBUG; + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + if (originalDebug !== undefined) { + process.env.DEBUG = originalDebug; + } else { + delete process.env.DEBUG; + } + consoleErrorSpy.mockRestore(); + }); + + it('should log errors when DEBUG=inquirerer is set', async () => { + process.env.DEBUG = 'inquirerer'; + const registry = new DefaultResolverRegistry(); + + registry.register('test.error', () => { + throw new Error('Test error message'); + }); + + await registry.resolve('test.error'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[inquirerer] Resolver 'test.error' failed:", + expect.any(Error) + ); + }); + + it('should not log errors when DEBUG is not set', async () => { + delete process.env.DEBUG; + const registry = new DefaultResolverRegistry(); + + registry.register('test.error', () => { + throw new Error('Test error message'); + }); + + await registry.resolve('test.error'); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/inquirerer/examples/defaultFrom-example.ts b/packages/inquirerer/examples/defaultFrom-example.ts new file mode 100644 index 0000000..f8857b8 --- /dev/null +++ b/packages/inquirerer/examples/defaultFrom-example.ts @@ -0,0 +1,104 @@ +import { Inquirerer, registerDefaultResolver } from '../src'; + +/** + * Example demonstrating the new defaultFrom feature + * + * This feature allows questions to automatically populate defaults from: + * - Git configuration (git.user.name, git.user.email) + * - Date/time values (date.year, date.iso, date.now) + * - Custom resolvers you register + */ + +async function basicExample() { + console.log('=== Basic Example: Git and Date Resolvers ===\n'); + + const questions = [ + { + name: 'authorName', + type: 'text' as const, + message: 'Author name?', + defaultFrom: 'git.user.name' // Auto-fills from git config + }, + { + name: 'authorEmail', + type: 'text' as const, + message: 'Author email?', + defaultFrom: 'git.user.email' // Auto-fills from git config + }, + { + name: 'year', + type: 'text' as const, + message: 'Copyright year?', + defaultFrom: 'date.year' // Auto-fills current year + } + ]; + + const prompter = new Inquirerer(); + const answers = await prompter.prompt({}, questions); + + console.log('\nAnswers:', answers); + prompter.close(); +} + +async function customResolverExample() { + console.log('\n=== Custom Resolver Example ===\n'); + + // Register custom resolvers + registerDefaultResolver('package.name', async () => { + // In a real app, you'd read from package.json + return 'my-awesome-package'; + }); + + registerDefaultResolver('cwd.name', () => { + return process.cwd().split('/').pop(); + }); + + const questions = [ + { + name: 'pkgName', + type: 'text' as const, + message: 'Package name?', + defaultFrom: 'package.name' // Uses custom resolver + }, + { + name: 'dirName', + type: 'text' as const, + message: 'Directory name?', + defaultFrom: 'cwd.name' // Uses custom resolver + } + ]; + + const prompter = new Inquirerer(); + const answers = await prompter.prompt({}, questions); + + console.log('\nAnswers:', answers); + prompter.close(); +} + +async function allAvailableResolvers() { + console.log('\n=== All Built-in Resolvers ===\n'); + + const builtInResolvers = [ + 'git.user.name', + 'git.user.email', + 'date.year', + 'date.month', + 'date.day', + 'date.now', + 'date.iso', + 'date.timestamp' + ]; + + console.log('Built-in resolvers available:'); + builtInResolvers.forEach(resolver => { + console.log(` - ${resolver}`); + }); +} + +// Run examples +(async () => { + await allAvailableResolvers(); + // Uncomment to run interactive examples: + // await basicExample(); + // await customResolverExample(); +})(); diff --git a/packages/inquirerer/src/index.ts b/packages/inquirerer/src/index.ts index ef58b66..0d5d424 100644 --- a/packages/inquirerer/src/index.ts +++ b/packages/inquirerer/src/index.ts @@ -1,3 +1,4 @@ export * from './commander'; export * from './prompt'; -export * from './question'; \ No newline at end of file +export * from './question'; +export * from './resolvers'; \ No newline at end of file diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index bd5a448..4fa8699 100644 --- a/packages/inquirerer/src/prompt.ts +++ b/packages/inquirerer/src/prompt.ts @@ -4,6 +4,7 @@ import { Readable, Writable } from 'stream'; import { KEY_CODES, TerminalKeypress } from './keypress'; import { AutocompleteQuestion, CheckboxQuestion, ConfirmQuestion, ListQuestion, NumberQuestion, OptionValue, Question, TextQuestion, Validation, Value } from './question'; +import { DefaultResolverRegistry, globalResolverRegistry } from './resolvers'; // import { writeFileSync } from 'fs'; // const debuglog = (obj: any) => { @@ -250,6 +251,7 @@ export interface InquirererOptions { useDefaults?: boolean; globalMaxLines?: number; mutateArgs?: boolean; + resolverRegistry?: DefaultResolverRegistry; } export class Inquirerer { @@ -261,6 +263,7 @@ export class Inquirerer { private useDefaults: boolean; private globalMaxLines: number; private mutateArgs: boolean; + private resolverRegistry: DefaultResolverRegistry; private handledKeys: Set = new Set(); @@ -273,7 +276,8 @@ export class Inquirerer { output = process.stdout, useDefaults = false, globalMaxLines = 10, - mutateArgs = true + mutateArgs = true, + resolverRegistry = globalResolverRegistry } = options ?? {} this.useDefaults = useDefaults; @@ -282,6 +286,7 @@ export class Inquirerer { this.mutateArgs = mutateArgs; this.input = input; this.globalMaxLines = globalMaxLines; + this.resolverRegistry = resolverRegistry; if (!noTty) { this.rl = readline.createInterface({ @@ -461,6 +466,9 @@ export class Inquirerer { const shouldMutate = options?.mutateArgs !== undefined ? options.mutateArgs : this.mutateArgs; let obj: any = shouldMutate ? argv : { ...argv }; + // Resolve dynamic defaults before processing questions + await this.resolveDynamicDefaults(questions); + // first loop through the question, and set any overrides in case other questions use objs for validation this.applyOverrides(argv, obj, questions); @@ -543,6 +551,43 @@ export class Inquirerer { return questions.some(question => question.required && this.isEmptyAnswer(argv[question.name])); } + /** + * Resolves the default value for a question using the resolver system. + * Priority: defaultFrom > default > undefined + */ + private async resolveQuestionDefault(question: Question): Promise { + // Try to resolve from defaultFrom first + if ('defaultFrom' in question && question.defaultFrom) { + const resolved = await this.resolverRegistry.resolve(question.defaultFrom); + if (resolved !== undefined) { + return resolved; + } + } + + // Fallback to static default + if ('default' in question) { + return question.default; + } + + return undefined; + } + + /** + * Resolves dynamic defaults for all questions that have defaultFrom specified. + * Updates the question.default property with the resolved value. + */ + private async resolveDynamicDefaults(questions: Question[]): Promise { + for (const question of questions) { + if ('defaultFrom' in question && question.defaultFrom) { + const resolved = await this.resolveQuestionDefault(question); + if (resolved !== undefined) { + // Update question.default with resolved value + (question as any).default = resolved; + } + } + } + } + private applyDefaultValues(questions: Question[], obj: any): void { questions.forEach(question => { if ('default' in question) { diff --git a/packages/inquirerer/src/question/types.ts b/packages/inquirerer/src/question/types.ts index fe26047..16552c2 100644 --- a/packages/inquirerer/src/question/types.ts +++ b/packages/inquirerer/src/question/types.ts @@ -18,6 +18,7 @@ export interface BaseQuestion { name: string; type: string; default?: any; + defaultFrom?: string; useDefault?: boolean; required?: boolean; message?: string; diff --git a/packages/inquirerer/src/resolvers/date.ts b/packages/inquirerer/src/resolvers/date.ts new file mode 100644 index 0000000..b41988a --- /dev/null +++ b/packages/inquirerer/src/resolvers/date.ts @@ -0,0 +1,13 @@ +import type { ResolverRegistry } from './types'; + +/** + * Built-in date/time resolvers. + */ +export const dateResolvers: ResolverRegistry = { + 'date.year': () => new Date().getFullYear().toString(), + 'date.month': () => (new Date().getMonth() + 1).toString().padStart(2, '0'), + 'date.day': () => new Date().getDate().toString().padStart(2, '0'), + 'date.now': () => new Date().toISOString(), + 'date.iso': () => new Date().toISOString().split('T')[0], // YYYY-MM-DD + 'date.timestamp': () => Date.now().toString(), +}; diff --git a/packages/inquirerer/src/resolvers/git.ts b/packages/inquirerer/src/resolvers/git.ts new file mode 100644 index 0000000..79c0f16 --- /dev/null +++ b/packages/inquirerer/src/resolvers/git.ts @@ -0,0 +1,28 @@ +import { execSync } from 'child_process'; +import type { ResolverRegistry } from './types'; + +/** + * Retrieves a git config value. + * @param key The git config key (e.g., 'user.name', 'user.email') + * @returns The config value as a string, or undefined if not found or error occurs + */ +export function getGitConfig(key: string): string | undefined { + try { + const result = execSync(`git config --global ${key}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] // Suppress stderr + }); + const trimmed = result.trim(); + return trimmed || undefined; // Treat empty string as undefined + } catch { + return undefined; + } +} + +/** + * Built-in git configuration resolvers. + */ +export const gitResolvers: ResolverRegistry = { + 'git.user.name': () => getGitConfig('user.name'), + 'git.user.email': () => getGitConfig('user.email'), +}; diff --git a/packages/inquirerer/src/resolvers/index.ts b/packages/inquirerer/src/resolvers/index.ts new file mode 100644 index 0000000..d67791c --- /dev/null +++ b/packages/inquirerer/src/resolvers/index.ts @@ -0,0 +1,113 @@ +import { gitResolvers } from './git'; +import { dateResolvers } from './date'; +import type { DefaultResolver, ResolverRegistry } from './types'; + +/** + * A registry for managing default value resolvers. + * Allows registration of custom resolvers and provides resolution logic. + */ +export class DefaultResolverRegistry { + private resolvers: ResolverRegistry; + + constructor(initialResolvers: ResolverRegistry = {}) { + this.resolvers = { ...initialResolvers }; + } + + /** + * Register a custom resolver. + * @param key The resolver key (e.g., 'git.user.name') + * @param resolver The resolver function + */ + register(key: string, resolver: DefaultResolver): void { + this.resolvers[key] = resolver; + } + + /** + * Unregister a resolver. + * @param key The resolver key to remove + */ + unregister(key: string): void { + delete this.resolvers[key]; + } + + /** + * Resolve a key to its value. + * Returns undefined if the resolver doesn't exist or if it throws an error. + * @param key The resolver key + * @returns The resolved value or undefined + */ + async resolve(key: string): Promise { + const resolver = this.resolvers[key]; + if (!resolver) { + return undefined; + } + + try { + const result = await Promise.resolve(resolver()); + // Treat empty strings as undefined + return result === '' ? undefined : result; + } catch (error) { + // Silent failure - log only in debug mode + if (process.env.DEBUG === 'inquirerer') { + console.error(`[inquirerer] Resolver '${key}' failed:`, error); + } + return undefined; + } + } + + /** + * Check if a resolver exists for the given key. + * @param key The resolver key + * @returns True if the resolver exists + */ + has(key: string): boolean { + return key in this.resolvers; + } + + /** + * Get all registered resolver keys. + * @returns Array of resolver keys + */ + keys(): string[] { + return Object.keys(this.resolvers); + } + + /** + * Create a copy of this registry with all current resolvers. + * @returns A new DefaultResolverRegistry instance + */ + clone(): DefaultResolverRegistry { + return new DefaultResolverRegistry({ ...this.resolvers }); + } +} + +/** + * Global resolver registry instance with built-in resolvers. + * This is the default registry used by Inquirerer unless a custom one is provided. + */ +export const globalResolverRegistry = new DefaultResolverRegistry({ + ...gitResolvers, + ...dateResolvers, +}); + +/** + * Convenience function to register a resolver on the global registry. + * @param key The resolver key + * @param resolver The resolver function + */ +export function registerDefaultResolver(key: string, resolver: DefaultResolver): void { + globalResolverRegistry.register(key, resolver); +} + +/** + * Convenience function to resolve a key using the global registry. + * @param key The resolver key + * @returns The resolved value or undefined + */ +export function resolveDefault(key: string): Promise { + return globalResolverRegistry.resolve(key); +} + +// Re-export types and utilities +export type { DefaultResolver, ResolverRegistry } from './types'; +export { getGitConfig } from './git'; diff --git a/packages/inquirerer/src/resolvers/types.ts b/packages/inquirerer/src/resolvers/types.ts new file mode 100644 index 0000000..61884f6 --- /dev/null +++ b/packages/inquirerer/src/resolvers/types.ts @@ -0,0 +1,13 @@ +/** + * A function that resolves a default value dynamically. + * Can be synchronous or asynchronous. + */ +export type DefaultResolver = () => Promise | any; + +/** + * A registry of resolver functions, keyed by their resolver name. + * Example: { 'git.user.name': () => getGitConfig('user.name') } + */ +export interface ResolverRegistry { + [key: string]: DefaultResolver; +}