diff --git a/packages/create-gen-app/README.md b/packages/create-gen-app/README.md index daa4a91..3aa31ff 100644 --- a/packages/create-gen-app/README.md +++ b/packages/create-gen-app/README.md @@ -37,15 +37,65 @@ npm install create-gen-app ## Library Usage -`create-gen-app` provides a modular set of classes to handle template cloning, caching, and processing. +`create-gen-app` provides both a high-level orchestrator and modular building blocks for template scaffolding. -### Core Components +### Quick Start with TemplateScaffolder -- **CacheManager**: Handles local caching of git repositories with TTL (Time-To-Live) support. -- **GitCloner**: Handles cloning git repositories. -- **Templatizer**: Handles variable extraction, user prompting, and template generation. +The easiest way to use `create-gen-app` is with the `TemplateScaffolder` class, which combines caching, cloning, and template processing into a single API: -### Example: Orchestration +```typescript +import { TemplateScaffolder } from 'create-gen-app'; + +const scaffolder = new TemplateScaffolder({ + toolName: 'my-cli', // Cache directory: ~/.my-cli/cache + defaultRepo: 'org/my-templates', // Default template repository + ttlMs: 7 * 24 * 60 * 60 * 1000, // Cache TTL: 1 week +}); + +// Scaffold a project from the default repo +await scaffolder.scaffold({ + outputDir: './my-project', + fromPath: 'starter', // Use the "starter" template variant + answers: { projectName: 'my-app' }, // Pre-populate answers +}); + +// Or scaffold from a specific repo +await scaffolder.scaffold({ + template: 'https://github.com/other/templates.git', + outputDir: './another-project', + branch: 'v2', +}); +``` + +### Template Repository Conventions + +`TemplateScaffolder` supports the `.boilerplates.json` convention for organizing multiple templates in a single repository: + +``` +my-templates/ +├── .boilerplates.json # { "dir": "templates" } +└── templates/ + ├── starter/ + │ ├── .boilerplate.json + │ └── ...template files... + └── advanced/ + ├── .boilerplate.json + └── ...template files... +``` + +When you call `scaffold({ fromPath: 'starter' })`, the scaffolder will: +1. Check if `starter/` exists directly in the repo root +2. If not, read `.boilerplates.json` and look for `templates/starter/` + +### Core Components (Building Blocks) + +For more control, you can use the individual components directly: + +- **CacheManager**: Handles local caching of git repositories with TTL support +- **GitCloner**: Handles cloning git repositories +- **Templatizer**: Handles variable extraction, user prompting, and template generation + +### Example: Manual Orchestration Here is how you can combine these components to create a full CLI pipeline (similar to `create-gen-app-test`): @@ -150,6 +200,28 @@ No code changes are needed; the generator discovers templates at runtime and wil ## API Overview +### TemplateScaffolder (Recommended) + +The high-level orchestrator that combines caching, cloning, and template processing: + +- `new TemplateScaffolder(config)`: Initialize with configuration: + - `toolName` (required): Name for cache directory (e.g., `'my-cli'` → `~/.my-cli/cache`) + - `defaultRepo`: Default template repository URL or `org/repo` shorthand + - `defaultBranch`: Default branch to clone + - `ttlMs`: Cache time-to-live in milliseconds + - `cacheBaseDir`: Override cache location (useful for tests) +- `scaffold(options)`: Scaffold a project from a template: + - `template`: Repository URL, local path, or `org/repo` shorthand (uses `defaultRepo` if not provided) + - `outputDir` (required): Output directory for generated project + - `fromPath`: Subdirectory within template to use + - `branch`: Branch to clone + - `answers`: Pre-populated answers to skip prompting + - `noTty`: Disable interactive prompts + - `prompter`: Reuse an existing Inquirerer instance +- `readBoilerplatesConfig(dir)`: Read `.boilerplates.json` from a template repo +- `readBoilerplateConfig(dir)`: Read `.boilerplate.json` from a template directory +- `getCacheManager()`, `getGitCloner()`, `getTemplatizer()`: Access underlying components + ### CacheManager - `new CacheManager(config)`: Initialize with `toolName` and optional `ttl`. - `get(key)`: Get path to cached repo if exists. diff --git a/packages/create-gen-app/__tests__/create-gen.test.ts b/packages/create-gen-app/__tests__/create-gen.test.ts index c399013..8590b03 100644 --- a/packages/create-gen-app/__tests__/create-gen.test.ts +++ b/packages/create-gen-app/__tests__/create-gen.test.ts @@ -218,7 +218,7 @@ module.exports = { projectQuestions: null, }; - await promptUser(extractedVariables, {}, false); + await promptUser(extractedVariables, {}, undefined, false); expect(mockPrompt).toHaveBeenCalled(); const questions = mockPrompt.mock.calls[0][1]; @@ -254,7 +254,7 @@ module.exports = { }, }; - await promptUser(extractedVariables, {}, false); + await promptUser(extractedVariables, {}, undefined, false); expect(mockPrompt).toHaveBeenCalled(); const questions = mockPrompt.mock.calls[0][1]; @@ -283,7 +283,7 @@ module.exports = { }; const argv = { projectName: 'pre-filled-project' }; - await promptUser(extractedVariables, argv, false); + await promptUser(extractedVariables, argv, undefined, false); expect(mockPrompt).toHaveBeenCalledWith( expect.objectContaining(argv), @@ -317,7 +317,7 @@ module.exports = { }; const argv = { USERFULLNAME: 'CLI User' }; - await promptUser(extractedVariables, argv, false); + await promptUser(extractedVariables, argv, undefined, false); const passedArgv = mockPrompt.mock.calls[0][0]; expect(passedArgv.fullName).toBeUndefined(); @@ -350,7 +350,7 @@ module.exports = { }; const argv = { MODULEDESC: 'CLI description' }; - await promptUser(extractedVariables, argv, false); + await promptUser(extractedVariables, argv, undefined, false); const passedArgv = mockPrompt.mock.calls[0][0]; expect(passedArgv.description).toBeUndefined(); @@ -384,7 +384,7 @@ module.exports = { }, }; - const answers = await promptUser(extractedVariables, {}, false); + const answers = await promptUser(extractedVariables, {}, undefined, false); expect(answers.fullName).toBe('Prompted User'); }); @@ -415,7 +415,7 @@ module.exports = { }, }; - const answers = await promptUser(extractedVariables, {}, false); + const answers = await promptUser(extractedVariables, {}, undefined, false); expect(answers.description).toBe('Prompted description'); expect(answers.moduleDesc).toBeUndefined(); }); diff --git a/packages/create-gen-app/src/index.ts b/packages/create-gen-app/src/index.ts index e725b16..0fff359 100644 --- a/packages/create-gen-app/src/index.ts +++ b/packages/create-gen-app/src/index.ts @@ -18,6 +18,8 @@ export * from './cache/cache-manager'; export * from './cache/types'; export * from './git/git-cloner'; export * from './git/types'; +export * from './scaffolder/template-scaffolder'; +export * from './scaffolder/types'; export * from './template/templatizer'; export * from './template/types'; export * from './utils/npm-version-check'; diff --git a/packages/create-gen-app/src/scaffolder/index.ts b/packages/create-gen-app/src/scaffolder/index.ts new file mode 100644 index 0000000..a5c9ab8 --- /dev/null +++ b/packages/create-gen-app/src/scaffolder/index.ts @@ -0,0 +1,2 @@ +export * from './template-scaffolder'; +export * from './types'; diff --git a/packages/create-gen-app/src/scaffolder/template-scaffolder.ts b/packages/create-gen-app/src/scaffolder/template-scaffolder.ts new file mode 100644 index 0000000..4ce353e --- /dev/null +++ b/packages/create-gen-app/src/scaffolder/template-scaffolder.ts @@ -0,0 +1,287 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { CacheManager } from '../cache/cache-manager'; +import { GitCloner } from '../git/git-cloner'; +import { Templatizer } from '../template/templatizer'; +import { + TemplateScaffolderConfig, + ScaffoldOptions, + ScaffoldResult, + BoilerplatesConfig, + BoilerplateConfig, +} from './types'; + +/** + * High-level orchestrator for template scaffolding operations. + * Combines CacheManager, GitCloner, and Templatizer into a single, easy-to-use API. + * + * @example + * ```typescript + * const scaffolder = new TemplateScaffolder({ + * toolName: 'my-cli', + * defaultRepo: 'https://github.com/org/templates.git', + * ttlMs: 7 * 24 * 60 * 60 * 1000, // 1 week + * }); + * + * await scaffolder.scaffold({ + * outputDir: './my-project', + * fromPath: 'starter', + * answers: { name: 'my-project' }, + * }); + * ``` + */ +export class TemplateScaffolder { + private config: TemplateScaffolderConfig; + private cacheManager: CacheManager; + private gitCloner: GitCloner; + private templatizer: Templatizer; + + constructor(config: TemplateScaffolderConfig) { + if (!config.toolName) { + throw new Error('TemplateScaffolder requires toolName in config'); + } + + this.config = config; + this.cacheManager = new CacheManager({ + toolName: config.toolName, + ttl: config.ttlMs, + baseDir: config.cacheBaseDir, + }); + this.gitCloner = new GitCloner(); + this.templatizer = new Templatizer(); + } + + /** + * Scaffold a new project from a template. + * + * Handles both local directories and remote git repositories. + * For remote repos, caching is used to avoid repeated cloning. + * + * @param options - Scaffold options + * @returns Scaffold result with output path and metadata + */ + async scaffold(options: ScaffoldOptions): Promise { + const template = options.template ?? this.config.defaultRepo; + if (!template) { + throw new Error( + 'No template specified and no defaultRepo configured. ' + + 'Either pass template in options or set defaultRepo in config.' + ); + } + + const branch = options.branch ?? this.config.defaultBranch; + const resolvedTemplate = this.resolveTemplatePath(template); + + if (this.isLocalPath(resolvedTemplate) && fs.existsSync(resolvedTemplate)) { + return this.scaffoldFromLocal(resolvedTemplate, options); + } + + return this.scaffoldFromRemote(resolvedTemplate, branch, options); + } + + /** + * Read the .boilerplates.json configuration from a template repository root. + */ + readBoilerplatesConfig(templateDir: string): BoilerplatesConfig | null { + const configPath = path.join(templateDir, '.boilerplates.json'); + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf-8'); + return JSON.parse(content) as BoilerplatesConfig; + } catch { + return null; + } + } + return null; + } + + /** + * Read the .boilerplate.json configuration from a boilerplate directory. + */ + readBoilerplateConfig(boilerplatePath: string): BoilerplateConfig | null { + const jsonPath = path.join(boilerplatePath, '.boilerplate.json'); + if (fs.existsSync(jsonPath)) { + try { + const content = fs.readFileSync(jsonPath, 'utf-8'); + return JSON.parse(content) as BoilerplateConfig; + } catch { + return null; + } + } + return null; + } + + /** + * Get the underlying CacheManager instance for advanced cache operations. + */ + getCacheManager(): CacheManager { + return this.cacheManager; + } + + /** + * Get the underlying GitCloner instance for advanced git operations. + */ + getGitCloner(): GitCloner { + return this.gitCloner; + } + + /** + * Get the underlying Templatizer instance for advanced template operations. + */ + getTemplatizer(): Templatizer { + return this.templatizer; + } + + private async scaffoldFromLocal( + templateDir: string, + options: ScaffoldOptions + ): Promise { + const { fromPath, resolvedTemplatePath } = this.resolveFromPath( + templateDir, + options.fromPath + ); + + const boilerplateConfig = this.readBoilerplateConfig(resolvedTemplatePath); + + const result = await this.templatizer.process(templateDir, options.outputDir, { + argv: options.answers, + noTty: options.noTty, + fromPath, + prompter: options.prompter, + }); + + return { + outputDir: result.outputDir, + cacheUsed: false, + cacheExpired: false, + templateDir, + fromPath, + questions: boilerplateConfig?.questions, + answers: result.answers, + }; + } + + private async scaffoldFromRemote( + templateUrl: string, + branch: string | undefined, + options: ScaffoldOptions + ): Promise { + const normalizedUrl = this.gitCloner.normalizeUrl(templateUrl); + const cacheKey = this.cacheManager.createKey(normalizedUrl, branch); + + const expiredMetadata = this.cacheManager.checkExpiration(cacheKey); + if (expiredMetadata) { + this.cacheManager.clear(cacheKey); + } + + let templateDir: string; + let cacheUsed = false; + + const cachedPath = this.cacheManager.get(cacheKey); + if (cachedPath && !expiredMetadata) { + templateDir = cachedPath; + cacheUsed = true; + } else { + const tempDest = path.join(this.cacheManager.getReposDir(), cacheKey); + this.gitCloner.clone(normalizedUrl, tempDest, { + branch, + depth: 1, + singleBranch: true, + }); + this.cacheManager.set(cacheKey, tempDest); + templateDir = tempDest; + } + + const { fromPath, resolvedTemplatePath } = this.resolveFromPath( + templateDir, + options.fromPath + ); + + const boilerplateConfig = this.readBoilerplateConfig(resolvedTemplatePath); + + const result = await this.templatizer.process(templateDir, options.outputDir, { + argv: options.answers, + noTty: options.noTty, + fromPath, + prompter: options.prompter, + }); + + return { + outputDir: result.outputDir, + cacheUsed, + cacheExpired: Boolean(expiredMetadata), + templateDir, + fromPath, + questions: boilerplateConfig?.questions, + answers: result.answers, + }; + } + + /** + * Resolve the fromPath using .boilerplates.json convention. + * + * Resolution order: + * 1. If explicit fromPath is provided and exists, use it directly + * 2. If .boilerplates.json exists with a dir field, prepend it to fromPath + * 3. Return the fromPath as-is + */ + private resolveFromPath( + templateDir: string, + fromPath?: string + ): { fromPath?: string; resolvedTemplatePath: string } { + if (!fromPath) { + return { + fromPath: undefined, + resolvedTemplatePath: templateDir, + }; + } + + const directPath = path.isAbsolute(fromPath) + ? fromPath + : path.join(templateDir, fromPath); + + if (fs.existsSync(directPath) && fs.statSync(directPath).isDirectory()) { + return { + fromPath: path.isAbsolute(fromPath) ? path.relative(templateDir, fromPath) : fromPath, + resolvedTemplatePath: directPath, + }; + } + + const rootConfig = this.readBoilerplatesConfig(templateDir); + if (rootConfig?.dir) { + const configBasedPath = path.join(templateDir, rootConfig.dir, fromPath); + if (fs.existsSync(configBasedPath) && fs.statSync(configBasedPath).isDirectory()) { + return { + fromPath: path.join(rootConfig.dir, fromPath), + resolvedTemplatePath: configBasedPath, + }; + } + } + + return { + fromPath, + resolvedTemplatePath: path.join(templateDir, fromPath), + }; + } + + private isLocalPath(value: string): boolean { + return ( + value.startsWith('.') || + value.startsWith('/') || + value.startsWith('~') || + (process.platform === 'win32' && /^[a-zA-Z]:/.test(value)) + ); + } + + private resolveTemplatePath(template: string): string { + if (this.isLocalPath(template)) { + if (template.startsWith('~')) { + const home = process.env.HOME || process.env.USERPROFILE || ''; + return path.join(home, template.slice(1)); + } + return path.resolve(template); + } + return template; + } +} diff --git a/packages/create-gen-app/src/scaffolder/types.ts b/packages/create-gen-app/src/scaffolder/types.ts new file mode 100644 index 0000000..9be0118 --- /dev/null +++ b/packages/create-gen-app/src/scaffolder/types.ts @@ -0,0 +1,147 @@ +import { Inquirerer } from 'inquirerer'; +import { Question } from 'inquirerer'; + +/** + * Configuration for TemplateScaffolder instance + */ +export interface TemplateScaffolderConfig { + /** + * Tool name used for cache directory naming (e.g., 'my-cli' -> ~/.my-cli/cache) + */ + toolName: string; + + /** + * Default template repository URL or path. + * Used when scaffold() is called without specifying a template. + */ + defaultRepo?: string; + + /** + * Default branch to use when cloning repositories + */ + defaultBranch?: string; + + /** + * Cache time-to-live in milliseconds. + * Cached templates older than this will be re-cloned. + * Default: no expiration + */ + ttlMs?: number; + + /** + * Base directory for cache storage. + * Useful for tests to avoid touching the real home directory. + */ + cacheBaseDir?: string; +} + +/** + * Options for a single scaffold operation + */ +export interface ScaffoldOptions { + /** + * Template repository URL, local path, or org/repo shorthand. + * If not provided, uses the defaultRepo from config. + */ + template?: string; + + /** + * Branch to clone (for remote repositories) + */ + branch?: string; + + /** + * Subdirectory within the template repository to use as the template root. + * Can be a direct path or a variant name that gets resolved via .boilerplates.json + */ + fromPath?: string; + + /** + * Output directory for the generated project + */ + outputDir: string; + + /** + * Pre-populated answers to skip prompting for known values + */ + answers?: Record; + + /** + * Disable TTY mode for non-interactive environments + */ + noTty?: boolean; + + /** + * Optional Inquirerer instance to reuse for prompting. + * If provided, the caller retains ownership and is responsible for closing it. + * If not provided, a new instance will be created and closed automatically. + */ + prompter?: Inquirerer; +} + +/** + * Result of a scaffold operation + */ +export interface ScaffoldResult { + /** + * Path to the generated output directory + */ + outputDir: string; + + /** + * Whether a cached template was used + */ + cacheUsed: boolean; + + /** + * Whether the cache was expired and refreshed + */ + cacheExpired: boolean; + + /** + * Path to the cached/cloned template directory + */ + templateDir: string; + + /** + * The resolved fromPath used for template processing + */ + fromPath?: string; + + /** + * Questions loaded from .boilerplate.json, if any + */ + questions?: Question[]; + + /** + * Answers collected during prompting + */ + answers: Record; +} + +/** + * Root configuration for a boilerplates repository. + * Stored in `.boilerplates.json` at the repository root. + */ +export interface BoilerplatesConfig { + /** + * Default directory containing boilerplate templates (e.g., "templates", "boilerplates") + */ + dir?: string; +} + +/** + * Configuration for a single boilerplate template. + * Stored in `.boilerplate.json` within each template directory. + */ +export interface BoilerplateConfig { + /** + * Optional type identifier for the boilerplate + */ + type?: string; + + /** + * Questions to prompt the user during scaffolding + */ + questions?: Question[]; +} diff --git a/packages/create-gen-app/src/template/prompt.ts b/packages/create-gen-app/src/template/prompt.ts index d3e863d..bae5377 100644 --- a/packages/create-gen-app/src/template/prompt.ts +++ b/packages/create-gen-app/src/template/prompt.ts @@ -71,12 +71,16 @@ function normalizeQuestionName(name: string): string { * Prompt the user for variable values * @param extractedVariables - Variables extracted from the template * @param argv - Command-line arguments to pre-populate answers - * @param noTty - Whether to disable TTY mode + * @param existingPrompter - Optional existing Inquirerer instance to reuse. + * If provided, the caller retains ownership and must close it themselves. + * If not provided, a new instance is created and closed automatically. + * @param noTty - Whether to disable TTY mode (only used when creating a new prompter) * @returns Answers from the user */ export async function promptUser( extractedVariables: ExtractedVariables, argv: Record = {}, + existingPrompter?: Inquirerer, noTty: boolean = false ): Promise> { const questions = generateQuestions(extractedVariables); @@ -87,9 +91,10 @@ export async function promptUser( const preparedArgv = mapArgvToQuestions(argv, questions); - const prompter = new Inquirerer({ - noTty - }); + // If an existing prompter is provided, use it (caller owns lifecycle) + // Otherwise, create a new one and close it when done + const prompter = existingPrompter ?? new Inquirerer({ noTty }); + const shouldClose = !existingPrompter; try { const promptAnswers = await prompter.prompt(preparedArgv, questions); @@ -98,7 +103,9 @@ export async function promptUser( ...promptAnswers, }; } finally { - prompter.close(); + if (shouldClose) { + prompter.close(); + } } } diff --git a/packages/create-gen-app/src/template/templatizer.ts b/packages/create-gen-app/src/template/templatizer.ts index dbdba72..4ec5636 100644 --- a/packages/create-gen-app/src/template/templatizer.ts +++ b/packages/create-gen-app/src/template/templatizer.ts @@ -15,7 +15,7 @@ export class Templatizer { * Process a local template directory (extract + prompt + replace) * @param templateDir - Local directory path (MUST be local, NOT git URL) * @param outputDir - Output directory for generated project - * @param options - Processing options (argv overrides, noTty) + * @param options - Processing options (argv overrides, noTty, prompter) * @returns Processing result */ async process( @@ -35,10 +35,11 @@ export class Templatizer { // Extract variables const variables = await this.extract(actualTemplateDir); - // Prompt for values + // Prompt for values (pass through optional prompter) const answers = await this.prompt( variables, options?.argv, + options?.prompter, options?.noTty ); @@ -61,13 +62,18 @@ export class Templatizer { /** * Prompt user for variables + * @param extracted - Extracted variables from template + * @param argv - Pre-populated answers + * @param prompter - Optional existing Inquirerer instance to reuse + * @param noTty - Whether to disable TTY mode (only used when creating a new prompter) */ async prompt( extracted: ExtractedVariables, argv?: Record, + prompter?: import('inquirerer').Inquirerer, noTty?: boolean ): Promise> { - return promptUser(extracted, argv ?? {}, noTty ?? false); + return promptUser(extracted, argv ?? {}, prompter, noTty ?? false); } /** diff --git a/packages/create-gen-app/src/template/types.ts b/packages/create-gen-app/src/template/types.ts index 5c4aa81..7b649b3 100644 --- a/packages/create-gen-app/src/template/types.ts +++ b/packages/create-gen-app/src/template/types.ts @@ -1,9 +1,17 @@ +import { Inquirerer } from 'inquirerer'; + import { ExtractedVariables } from '../types'; export interface ProcessOptions { argv?: Record; noTty?: boolean; fromPath?: string; + /** + * Optional Inquirerer instance to reuse for prompting. + * If provided, the caller retains ownership and is responsible for closing it. + * If not provided, a new instance will be created and closed automatically. + */ + prompter?: Inquirerer; } export interface TemplatizerResult {