Skip to content
Merged
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
554 changes: 554 additions & 0 deletions packages/inquirerer/__tests__/defaultFrom.test.ts

Large diffs are not rendered by default.

424 changes: 424 additions & 0 deletions packages/inquirerer/__tests__/resolvers.test.ts

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions packages/inquirerer/examples/defaultFrom-example.ts
Original file line number Diff line number Diff line change
@@ -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();
})();
3 changes: 2 additions & 1 deletion packages/inquirerer/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './commander';
export * from './prompt';
export * from './question';
export * from './question';
export * from './resolvers';
47 changes: 46 additions & 1 deletion packages/inquirerer/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -250,6 +251,7 @@ export interface InquirererOptions {
useDefaults?: boolean;
globalMaxLines?: number;
mutateArgs?: boolean;
resolverRegistry?: DefaultResolverRegistry;

}
export class Inquirerer {
Expand All @@ -261,6 +263,7 @@ export class Inquirerer {
private useDefaults: boolean;
private globalMaxLines: number;
private mutateArgs: boolean;
private resolverRegistry: DefaultResolverRegistry;

private handledKeys: Set<string> = new Set();

Expand All @@ -273,7 +276,8 @@ export class Inquirerer {
output = process.stdout,
useDefaults = false,
globalMaxLines = 10,
mutateArgs = true
mutateArgs = true,
resolverRegistry = globalResolverRegistry
} = options ?? {}

this.useDefaults = useDefaults;
Expand All @@ -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({
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<any> {
// 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<void> {
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) {
Expand Down
1 change: 1 addition & 0 deletions packages/inquirerer/src/question/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface BaseQuestion {
name: string;
type: string;
default?: any;
defaultFrom?: string;
useDefault?: boolean;
required?: boolean;
message?: string;
Expand Down
13 changes: 13 additions & 0 deletions packages/inquirerer/src/resolvers/date.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
28 changes: 28 additions & 0 deletions packages/inquirerer/src/resolvers/git.ts
Original file line number Diff line number Diff line change
@@ -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'),
};
113 changes: 113 additions & 0 deletions packages/inquirerer/src/resolvers/index.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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<any> {
return globalResolverRegistry.resolve(key);
}

// Re-export types and utilities
export type { DefaultResolver, ResolverRegistry } from './types';
export { getGitConfig } from './git';
13 changes: 13 additions & 0 deletions packages/inquirerer/src/resolvers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* A function that resolves a default value dynamically.
* Can be synchronous or asynchronous.
*/
export type DefaultResolver = () => Promise<any> | any;

/**
* A registry of resolver functions, keyed by their resolver name.
* Example: { 'git.user.name': () => getGitConfig('user.name') }
*/
export interface ResolverRegistry {
[key: string]: DefaultResolver;
}
Loading