create-gen-app 0.6.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,15 +37,65 @@ npm install create-gen-app
37
37
 
38
38
  ## Library Usage
39
39
 
40
- `create-gen-app` provides a modular set of classes to handle template cloning, caching, and processing.
40
+ `create-gen-app` provides both a high-level orchestrator and modular building blocks for template scaffolding.
41
41
 
42
- ### Core Components
42
+ ### Quick Start with TemplateScaffolder
43
43
 
44
- - **CacheManager**: Handles local caching of git repositories with TTL (Time-To-Live) support.
45
- - **GitCloner**: Handles cloning git repositories.
46
- - **Templatizer**: Handles variable extraction, user prompting, and template generation.
44
+ The easiest way to use `create-gen-app` is with the `TemplateScaffolder` class, which combines caching, cloning, and template processing into a single API:
47
45
 
48
- ### Example: Orchestration
46
+ ```typescript
47
+ import { TemplateScaffolder } from 'create-gen-app';
48
+
49
+ const scaffolder = new TemplateScaffolder({
50
+ toolName: 'my-cli', // Cache directory: ~/.my-cli/cache
51
+ defaultRepo: 'org/my-templates', // Default template repository
52
+ ttlMs: 7 * 24 * 60 * 60 * 1000, // Cache TTL: 1 week
53
+ });
54
+
55
+ // Scaffold a project from the default repo
56
+ await scaffolder.scaffold({
57
+ outputDir: './my-project',
58
+ fromPath: 'starter', // Use the "starter" template variant
59
+ answers: { projectName: 'my-app' }, // Pre-populate answers
60
+ });
61
+
62
+ // Or scaffold from a specific repo
63
+ await scaffolder.scaffold({
64
+ template: 'https://github.com/other/templates.git',
65
+ outputDir: './another-project',
66
+ branch: 'v2',
67
+ });
68
+ ```
69
+
70
+ ### Template Repository Conventions
71
+
72
+ `TemplateScaffolder` supports the `.boilerplates.json` convention for organizing multiple templates in a single repository:
73
+
74
+ ```
75
+ my-templates/
76
+ ├── .boilerplates.json # { "dir": "templates" }
77
+ └── templates/
78
+ ├── starter/
79
+ │ ├── .boilerplate.json
80
+ │ └── ...template files...
81
+ └── advanced/
82
+ ├── .boilerplate.json
83
+ └── ...template files...
84
+ ```
85
+
86
+ When you call `scaffold({ fromPath: 'starter' })`, the scaffolder will:
87
+ 1. Check if `starter/` exists directly in the repo root
88
+ 2. If not, read `.boilerplates.json` and look for `templates/starter/`
89
+
90
+ ### Core Components (Building Blocks)
91
+
92
+ For more control, you can use the individual components directly:
93
+
94
+ - **CacheManager**: Handles local caching of git repositories with TTL support
95
+ - **GitCloner**: Handles cloning git repositories
96
+ - **Templatizer**: Handles variable extraction, user prompting, and template generation
97
+
98
+ ### Example: Manual Orchestration
49
99
 
50
100
  Here is how you can combine these components to create a full CLI pipeline (similar to `create-gen-app-test`):
51
101
 
@@ -150,6 +200,28 @@ No code changes are needed; the generator discovers templates at runtime and wil
150
200
 
151
201
  ## API Overview
152
202
 
203
+ ### TemplateScaffolder (Recommended)
204
+
205
+ The high-level orchestrator that combines caching, cloning, and template processing:
206
+
207
+ - `new TemplateScaffolder(config)`: Initialize with configuration:
208
+ - `toolName` (required): Name for cache directory (e.g., `'my-cli'` → `~/.my-cli/cache`)
209
+ - `defaultRepo`: Default template repository URL or `org/repo` shorthand
210
+ - `defaultBranch`: Default branch to clone
211
+ - `ttlMs`: Cache time-to-live in milliseconds
212
+ - `cacheBaseDir`: Override cache location (useful for tests)
213
+ - `scaffold(options)`: Scaffold a project from a template:
214
+ - `template`: Repository URL, local path, or `org/repo` shorthand (uses `defaultRepo` if not provided)
215
+ - `outputDir` (required): Output directory for generated project
216
+ - `fromPath`: Subdirectory within template to use
217
+ - `branch`: Branch to clone
218
+ - `answers`: Pre-populated answers to skip prompting
219
+ - `noTty`: Disable interactive prompts
220
+ - `prompter`: Reuse an existing Inquirerer instance
221
+ - `readBoilerplatesConfig(dir)`: Read `.boilerplates.json` from a template repo
222
+ - `readBoilerplateConfig(dir)`: Read `.boilerplate.json` from a template directory
223
+ - `getCacheManager()`, `getGitCloner()`, `getTemplatizer()`: Access underlying components
224
+
153
225
  ### CacheManager
154
226
  - `new CacheManager(config)`: Initialize with `toolName` and optional `ttl`.
155
227
  - `get(key)`: Get path to cached repo if exists.
package/esm/index.js CHANGED
@@ -11,6 +11,8 @@ export * from './cache/cache-manager';
11
11
  export * from './cache/types';
12
12
  export * from './git/git-cloner';
13
13
  export * from './git/types';
14
+ export * from './scaffolder/template-scaffolder';
15
+ export * from './scaffolder/types';
14
16
  export * from './template/templatizer';
15
17
  export * from './template/types';
16
18
  export * from './utils/npm-version-check';
@@ -0,0 +1,2 @@
1
+ export * from './template-scaffolder';
2
+ export * from './types';
@@ -0,0 +1,302 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { CacheManager } from '../cache/cache-manager';
4
+ import { GitCloner } from '../git/git-cloner';
5
+ import { Templatizer } from '../template/templatizer';
6
+ /**
7
+ * High-level orchestrator for template scaffolding operations.
8
+ * Combines CacheManager, GitCloner, and Templatizer into a single, easy-to-use API.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const scaffolder = new TemplateScaffolder({
13
+ * toolName: 'my-cli',
14
+ * defaultRepo: 'https://github.com/org/templates.git',
15
+ * ttlMs: 7 * 24 * 60 * 60 * 1000, // 1 week
16
+ * });
17
+ *
18
+ * await scaffolder.scaffold({
19
+ * outputDir: './my-project',
20
+ * fromPath: 'starter',
21
+ * answers: { name: 'my-project' },
22
+ * });
23
+ * ```
24
+ */
25
+ export class TemplateScaffolder {
26
+ config;
27
+ cacheManager;
28
+ gitCloner;
29
+ templatizer;
30
+ constructor(config) {
31
+ if (!config.toolName) {
32
+ throw new Error('TemplateScaffolder requires toolName in config');
33
+ }
34
+ this.config = config;
35
+ this.cacheManager = new CacheManager({
36
+ toolName: config.toolName,
37
+ ttl: config.ttlMs,
38
+ baseDir: config.cacheBaseDir,
39
+ });
40
+ this.gitCloner = new GitCloner();
41
+ this.templatizer = new Templatizer();
42
+ }
43
+ /**
44
+ * Scaffold a new project from a template.
45
+ *
46
+ * Handles both local directories and remote git repositories.
47
+ * For remote repos, caching is used to avoid repeated cloning.
48
+ *
49
+ * @param options - Scaffold options
50
+ * @returns Scaffold result with output path and metadata
51
+ */
52
+ async scaffold(options) {
53
+ const template = options.template ?? this.config.defaultRepo;
54
+ if (!template) {
55
+ throw new Error('No template specified and no defaultRepo configured. ' +
56
+ 'Either pass template in options or set defaultRepo in config.');
57
+ }
58
+ const branch = options.branch ?? this.config.defaultBranch;
59
+ const resolvedTemplate = this.resolveTemplatePath(template);
60
+ if (this.isLocalPath(resolvedTemplate) && fs.existsSync(resolvedTemplate)) {
61
+ return this.scaffoldFromLocal(resolvedTemplate, options);
62
+ }
63
+ return this.scaffoldFromRemote(resolvedTemplate, branch, options);
64
+ }
65
+ /**
66
+ * Inspect a template without scaffolding.
67
+ * Clones/caches the template and reads its .boilerplate.json configuration
68
+ * without copying any files to an output directory.
69
+ *
70
+ * This is useful for metadata-driven workflows where you need to know
71
+ * the template's type or other configuration before deciding how to handle it.
72
+ *
73
+ * @param options - Inspect options
74
+ * @returns Inspect result with template metadata
75
+ */
76
+ inspect(options) {
77
+ const template = options.template ?? this.config.defaultRepo;
78
+ if (!template) {
79
+ throw new Error('No template specified and no defaultRepo configured. ' +
80
+ 'Either pass template in options or set defaultRepo in config.');
81
+ }
82
+ const branch = options.branch ?? this.config.defaultBranch;
83
+ const resolvedTemplate = this.resolveTemplatePath(template);
84
+ if (this.isLocalPath(resolvedTemplate) && fs.existsSync(resolvedTemplate)) {
85
+ return this.inspectLocal(resolvedTemplate, options.fromPath);
86
+ }
87
+ return this.inspectRemote(resolvedTemplate, branch, options.fromPath);
88
+ }
89
+ /**
90
+ * Read the .boilerplates.json configuration from a template repository root.
91
+ */
92
+ readBoilerplatesConfig(templateDir) {
93
+ const configPath = path.join(templateDir, '.boilerplates.json');
94
+ if (fs.existsSync(configPath)) {
95
+ try {
96
+ const content = fs.readFileSync(configPath, 'utf-8');
97
+ return JSON.parse(content);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+ /**
106
+ * Read the .boilerplate.json configuration from a boilerplate directory.
107
+ */
108
+ readBoilerplateConfig(boilerplatePath) {
109
+ const jsonPath = path.join(boilerplatePath, '.boilerplate.json');
110
+ if (fs.existsSync(jsonPath)) {
111
+ try {
112
+ const content = fs.readFileSync(jsonPath, 'utf-8');
113
+ return JSON.parse(content);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Get the underlying CacheManager instance for advanced cache operations.
123
+ */
124
+ getCacheManager() {
125
+ return this.cacheManager;
126
+ }
127
+ /**
128
+ * Get the underlying GitCloner instance for advanced git operations.
129
+ */
130
+ getGitCloner() {
131
+ return this.gitCloner;
132
+ }
133
+ /**
134
+ * Get the underlying Templatizer instance for advanced template operations.
135
+ */
136
+ getTemplatizer() {
137
+ return this.templatizer;
138
+ }
139
+ inspectLocal(templateDir, fromPath) {
140
+ const { fromPath: resolvedFromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, fromPath);
141
+ const config = this.readBoilerplateConfig(resolvedTemplatePath);
142
+ return {
143
+ templateDir,
144
+ resolvedFromPath,
145
+ resolvedTemplatePath,
146
+ cacheUsed: false,
147
+ cacheExpired: false,
148
+ config,
149
+ };
150
+ }
151
+ inspectRemote(templateUrl, branch, fromPath) {
152
+ const normalizedUrl = this.gitCloner.normalizeUrl(templateUrl);
153
+ const cacheKey = this.cacheManager.createKey(normalizedUrl, branch);
154
+ const expiredMetadata = this.cacheManager.checkExpiration(cacheKey);
155
+ if (expiredMetadata) {
156
+ this.cacheManager.clear(cacheKey);
157
+ }
158
+ let templateDir;
159
+ let cacheUsed = false;
160
+ const cachedPath = this.cacheManager.get(cacheKey);
161
+ if (cachedPath && !expiredMetadata) {
162
+ templateDir = cachedPath;
163
+ cacheUsed = true;
164
+ }
165
+ else {
166
+ const tempDest = path.join(this.cacheManager.getReposDir(), cacheKey);
167
+ this.gitCloner.clone(normalizedUrl, tempDest, {
168
+ branch,
169
+ depth: 1,
170
+ singleBranch: true,
171
+ });
172
+ this.cacheManager.set(cacheKey, tempDest);
173
+ templateDir = tempDest;
174
+ }
175
+ const { fromPath: resolvedFromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, fromPath);
176
+ const config = this.readBoilerplateConfig(resolvedTemplatePath);
177
+ return {
178
+ templateDir,
179
+ resolvedFromPath,
180
+ resolvedTemplatePath,
181
+ cacheUsed,
182
+ cacheExpired: Boolean(expiredMetadata),
183
+ config,
184
+ };
185
+ }
186
+ async scaffoldFromLocal(templateDir, options) {
187
+ const { fromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, options.fromPath);
188
+ const boilerplateConfig = this.readBoilerplateConfig(resolvedTemplatePath);
189
+ const result = await this.templatizer.process(templateDir, options.outputDir, {
190
+ argv: options.answers,
191
+ noTty: options.noTty,
192
+ fromPath,
193
+ prompter: options.prompter,
194
+ });
195
+ return {
196
+ outputDir: result.outputDir,
197
+ cacheUsed: false,
198
+ cacheExpired: false,
199
+ templateDir,
200
+ fromPath,
201
+ questions: boilerplateConfig?.questions,
202
+ answers: result.answers,
203
+ };
204
+ }
205
+ async scaffoldFromRemote(templateUrl, branch, options) {
206
+ const normalizedUrl = this.gitCloner.normalizeUrl(templateUrl);
207
+ const cacheKey = this.cacheManager.createKey(normalizedUrl, branch);
208
+ const expiredMetadata = this.cacheManager.checkExpiration(cacheKey);
209
+ if (expiredMetadata) {
210
+ this.cacheManager.clear(cacheKey);
211
+ }
212
+ let templateDir;
213
+ let cacheUsed = false;
214
+ const cachedPath = this.cacheManager.get(cacheKey);
215
+ if (cachedPath && !expiredMetadata) {
216
+ templateDir = cachedPath;
217
+ cacheUsed = true;
218
+ }
219
+ else {
220
+ const tempDest = path.join(this.cacheManager.getReposDir(), cacheKey);
221
+ this.gitCloner.clone(normalizedUrl, tempDest, {
222
+ branch,
223
+ depth: 1,
224
+ singleBranch: true,
225
+ });
226
+ this.cacheManager.set(cacheKey, tempDest);
227
+ templateDir = tempDest;
228
+ }
229
+ const { fromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, options.fromPath);
230
+ const boilerplateConfig = this.readBoilerplateConfig(resolvedTemplatePath);
231
+ const result = await this.templatizer.process(templateDir, options.outputDir, {
232
+ argv: options.answers,
233
+ noTty: options.noTty,
234
+ fromPath,
235
+ prompter: options.prompter,
236
+ });
237
+ return {
238
+ outputDir: result.outputDir,
239
+ cacheUsed,
240
+ cacheExpired: Boolean(expiredMetadata),
241
+ templateDir,
242
+ fromPath,
243
+ questions: boilerplateConfig?.questions,
244
+ answers: result.answers,
245
+ };
246
+ }
247
+ /**
248
+ * Resolve the fromPath using .boilerplates.json convention.
249
+ *
250
+ * Resolution order:
251
+ * 1. If explicit fromPath is provided and exists, use it directly
252
+ * 2. If .boilerplates.json exists with a dir field, prepend it to fromPath
253
+ * 3. Return the fromPath as-is
254
+ */
255
+ resolveFromPath(templateDir, fromPath) {
256
+ if (!fromPath) {
257
+ return {
258
+ fromPath: undefined,
259
+ resolvedTemplatePath: templateDir,
260
+ };
261
+ }
262
+ const directPath = path.isAbsolute(fromPath)
263
+ ? fromPath
264
+ : path.join(templateDir, fromPath);
265
+ if (fs.existsSync(directPath) && fs.statSync(directPath).isDirectory()) {
266
+ return {
267
+ fromPath: path.isAbsolute(fromPath) ? path.relative(templateDir, fromPath) : fromPath,
268
+ resolvedTemplatePath: directPath,
269
+ };
270
+ }
271
+ const rootConfig = this.readBoilerplatesConfig(templateDir);
272
+ if (rootConfig?.dir) {
273
+ const configBasedPath = path.join(templateDir, rootConfig.dir, fromPath);
274
+ if (fs.existsSync(configBasedPath) && fs.statSync(configBasedPath).isDirectory()) {
275
+ return {
276
+ fromPath: path.join(rootConfig.dir, fromPath),
277
+ resolvedTemplatePath: configBasedPath,
278
+ };
279
+ }
280
+ }
281
+ return {
282
+ fromPath,
283
+ resolvedTemplatePath: path.join(templateDir, fromPath),
284
+ };
285
+ }
286
+ isLocalPath(value) {
287
+ return (value.startsWith('.') ||
288
+ value.startsWith('/') ||
289
+ value.startsWith('~') ||
290
+ (process.platform === 'win32' && /^[a-zA-Z]:/.test(value)));
291
+ }
292
+ resolveTemplatePath(template) {
293
+ if (this.isLocalPath(template)) {
294
+ if (template.startsWith('~')) {
295
+ const home = process.env.HOME || process.env.USERPROFILE || '';
296
+ return path.join(home, template.slice(1));
297
+ }
298
+ return path.resolve(template);
299
+ }
300
+ return template;
301
+ }
302
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -54,18 +54,22 @@ function normalizeQuestionName(name) {
54
54
  * Prompt the user for variable values
55
55
  * @param extractedVariables - Variables extracted from the template
56
56
  * @param argv - Command-line arguments to pre-populate answers
57
- * @param noTty - Whether to disable TTY mode
57
+ * @param existingPrompter - Optional existing Inquirerer instance to reuse.
58
+ * If provided, the caller retains ownership and must close it themselves.
59
+ * If not provided, a new instance is created and closed automatically.
60
+ * @param noTty - Whether to disable TTY mode (only used when creating a new prompter)
58
61
  * @returns Answers from the user
59
62
  */
60
- export async function promptUser(extractedVariables, argv = {}, noTty = false) {
63
+ export async function promptUser(extractedVariables, argv = {}, existingPrompter, noTty = false) {
61
64
  const questions = generateQuestions(extractedVariables);
62
65
  if (questions.length === 0) {
63
66
  return argv;
64
67
  }
65
68
  const preparedArgv = mapArgvToQuestions(argv, questions);
66
- const prompter = new Inquirerer({
67
- noTty
68
- });
69
+ // If an existing prompter is provided, use it (caller owns lifecycle)
70
+ // Otherwise, create a new one and close it when done
71
+ const prompter = existingPrompter ?? new Inquirerer({ noTty });
72
+ const shouldClose = !existingPrompter;
69
73
  try {
70
74
  const promptAnswers = await prompter.prompt(preparedArgv, questions);
71
75
  return {
@@ -74,7 +78,9 @@ export async function promptUser(extractedVariables, argv = {}, noTty = false) {
74
78
  };
75
79
  }
76
80
  finally {
77
- prompter.close();
81
+ if (shouldClose) {
82
+ prompter.close();
83
+ }
78
84
  }
79
85
  }
80
86
  function mapArgvToQuestions(argv, questions) {
@@ -11,7 +11,7 @@ export class Templatizer {
11
11
  * Process a local template directory (extract + prompt + replace)
12
12
  * @param templateDir - Local directory path (MUST be local, NOT git URL)
13
13
  * @param outputDir - Output directory for generated project
14
- * @param options - Processing options (argv overrides, noTty)
14
+ * @param options - Processing options (argv overrides, noTty, prompter)
15
15
  * @returns Processing result
16
16
  */
17
17
  async process(templateDir, outputDir, options) {
@@ -23,8 +23,8 @@ export class Templatizer {
23
23
  this.validateTemplateDir(actualTemplateDir);
24
24
  // Extract variables
25
25
  const variables = await this.extract(actualTemplateDir);
26
- // Prompt for values
27
- const answers = await this.prompt(variables, options?.argv, options?.noTty);
26
+ // Prompt for values (pass through optional prompter)
27
+ const answers = await this.prompt(variables, options?.argv, options?.prompter, options?.noTty);
28
28
  // Replace variables
29
29
  await this.replace(actualTemplateDir, outputDir, variables, answers);
30
30
  return {
@@ -41,9 +41,13 @@ export class Templatizer {
41
41
  }
42
42
  /**
43
43
  * Prompt user for variables
44
+ * @param extracted - Extracted variables from template
45
+ * @param argv - Pre-populated answers
46
+ * @param prompter - Optional existing Inquirerer instance to reuse
47
+ * @param noTty - Whether to disable TTY mode (only used when creating a new prompter)
44
48
  */
45
- async prompt(extracted, argv, noTty) {
46
- return promptUser(extracted, argv ?? {}, noTty ?? false);
49
+ async prompt(extracted, argv, prompter, noTty) {
50
+ return promptUser(extracted, argv ?? {}, prompter, noTty ?? false);
47
51
  }
48
52
  /**
49
53
  * Replace variables in template
package/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export * from './cache/cache-manager';
6
6
  export * from './cache/types';
7
7
  export * from './git/git-cloner';
8
8
  export * from './git/types';
9
+ export * from './scaffolder/template-scaffolder';
10
+ export * from './scaffolder/types';
9
11
  export * from './template/templatizer';
10
12
  export * from './template/types';
11
13
  export * from './utils/npm-version-check';
package/index.js CHANGED
@@ -32,6 +32,8 @@ __exportStar(require("./cache/cache-manager"), exports);
32
32
  __exportStar(require("./cache/types"), exports);
33
33
  __exportStar(require("./git/git-cloner"), exports);
34
34
  __exportStar(require("./git/types"), exports);
35
+ __exportStar(require("./scaffolder/template-scaffolder"), exports);
36
+ __exportStar(require("./scaffolder/types"), exports);
35
37
  __exportStar(require("./template/templatizer"), exports);
36
38
  __exportStar(require("./template/types"), exports);
37
39
  __exportStar(require("./utils/npm-version-check"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-gen-app",
3
- "version": "0.6.4",
3
+ "version": "0.8.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Clone and customize template repositories with variable replacement",
6
6
  "main": "index.js",
@@ -36,5 +36,5 @@
36
36
  "makage": "0.1.8"
37
37
  },
38
38
  "keywords": [],
39
- "gitHead": "acd6339f6890d5609a9d7b7ce2799d752b2a766e"
39
+ "gitHead": "9ab0a7a8b90ccedd5f9bbde7dcdaef424c7f5acd"
40
40
  }
@@ -0,0 +1,2 @@
1
+ export * from './template-scaffolder';
2
+ export * from './types';
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./template-scaffolder"), exports);
18
+ __exportStar(require("./types"), exports);
@@ -0,0 +1,87 @@
1
+ import { CacheManager } from '../cache/cache-manager';
2
+ import { GitCloner } from '../git/git-cloner';
3
+ import { Templatizer } from '../template/templatizer';
4
+ import { TemplateScaffolderConfig, ScaffoldOptions, ScaffoldResult, BoilerplatesConfig, BoilerplateConfig, InspectOptions, InspectResult } from './types';
5
+ /**
6
+ * High-level orchestrator for template scaffolding operations.
7
+ * Combines CacheManager, GitCloner, and Templatizer into a single, easy-to-use API.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * const scaffolder = new TemplateScaffolder({
12
+ * toolName: 'my-cli',
13
+ * defaultRepo: 'https://github.com/org/templates.git',
14
+ * ttlMs: 7 * 24 * 60 * 60 * 1000, // 1 week
15
+ * });
16
+ *
17
+ * await scaffolder.scaffold({
18
+ * outputDir: './my-project',
19
+ * fromPath: 'starter',
20
+ * answers: { name: 'my-project' },
21
+ * });
22
+ * ```
23
+ */
24
+ export declare class TemplateScaffolder {
25
+ private config;
26
+ private cacheManager;
27
+ private gitCloner;
28
+ private templatizer;
29
+ constructor(config: TemplateScaffolderConfig);
30
+ /**
31
+ * Scaffold a new project from a template.
32
+ *
33
+ * Handles both local directories and remote git repositories.
34
+ * For remote repos, caching is used to avoid repeated cloning.
35
+ *
36
+ * @param options - Scaffold options
37
+ * @returns Scaffold result with output path and metadata
38
+ */
39
+ scaffold(options: ScaffoldOptions): Promise<ScaffoldResult>;
40
+ /**
41
+ * Inspect a template without scaffolding.
42
+ * Clones/caches the template and reads its .boilerplate.json configuration
43
+ * without copying any files to an output directory.
44
+ *
45
+ * This is useful for metadata-driven workflows where you need to know
46
+ * the template's type or other configuration before deciding how to handle it.
47
+ *
48
+ * @param options - Inspect options
49
+ * @returns Inspect result with template metadata
50
+ */
51
+ inspect(options: InspectOptions): InspectResult;
52
+ /**
53
+ * Read the .boilerplates.json configuration from a template repository root.
54
+ */
55
+ readBoilerplatesConfig(templateDir: string): BoilerplatesConfig | null;
56
+ /**
57
+ * Read the .boilerplate.json configuration from a boilerplate directory.
58
+ */
59
+ readBoilerplateConfig(boilerplatePath: string): BoilerplateConfig | null;
60
+ /**
61
+ * Get the underlying CacheManager instance for advanced cache operations.
62
+ */
63
+ getCacheManager(): CacheManager;
64
+ /**
65
+ * Get the underlying GitCloner instance for advanced git operations.
66
+ */
67
+ getGitCloner(): GitCloner;
68
+ /**
69
+ * Get the underlying Templatizer instance for advanced template operations.
70
+ */
71
+ getTemplatizer(): Templatizer;
72
+ private inspectLocal;
73
+ private inspectRemote;
74
+ private scaffoldFromLocal;
75
+ private scaffoldFromRemote;
76
+ /**
77
+ * Resolve the fromPath using .boilerplates.json convention.
78
+ *
79
+ * Resolution order:
80
+ * 1. If explicit fromPath is provided and exists, use it directly
81
+ * 2. If .boilerplates.json exists with a dir field, prepend it to fromPath
82
+ * 3. Return the fromPath as-is
83
+ */
84
+ private resolveFromPath;
85
+ private isLocalPath;
86
+ private resolveTemplatePath;
87
+ }
@@ -0,0 +1,339 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.TemplateScaffolder = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const cache_manager_1 = require("../cache/cache-manager");
40
+ const git_cloner_1 = require("../git/git-cloner");
41
+ const templatizer_1 = require("../template/templatizer");
42
+ /**
43
+ * High-level orchestrator for template scaffolding operations.
44
+ * Combines CacheManager, GitCloner, and Templatizer into a single, easy-to-use API.
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * const scaffolder = new TemplateScaffolder({
49
+ * toolName: 'my-cli',
50
+ * defaultRepo: 'https://github.com/org/templates.git',
51
+ * ttlMs: 7 * 24 * 60 * 60 * 1000, // 1 week
52
+ * });
53
+ *
54
+ * await scaffolder.scaffold({
55
+ * outputDir: './my-project',
56
+ * fromPath: 'starter',
57
+ * answers: { name: 'my-project' },
58
+ * });
59
+ * ```
60
+ */
61
+ class TemplateScaffolder {
62
+ config;
63
+ cacheManager;
64
+ gitCloner;
65
+ templatizer;
66
+ constructor(config) {
67
+ if (!config.toolName) {
68
+ throw new Error('TemplateScaffolder requires toolName in config');
69
+ }
70
+ this.config = config;
71
+ this.cacheManager = new cache_manager_1.CacheManager({
72
+ toolName: config.toolName,
73
+ ttl: config.ttlMs,
74
+ baseDir: config.cacheBaseDir,
75
+ });
76
+ this.gitCloner = new git_cloner_1.GitCloner();
77
+ this.templatizer = new templatizer_1.Templatizer();
78
+ }
79
+ /**
80
+ * Scaffold a new project from a template.
81
+ *
82
+ * Handles both local directories and remote git repositories.
83
+ * For remote repos, caching is used to avoid repeated cloning.
84
+ *
85
+ * @param options - Scaffold options
86
+ * @returns Scaffold result with output path and metadata
87
+ */
88
+ async scaffold(options) {
89
+ const template = options.template ?? this.config.defaultRepo;
90
+ if (!template) {
91
+ throw new Error('No template specified and no defaultRepo configured. ' +
92
+ 'Either pass template in options or set defaultRepo in config.');
93
+ }
94
+ const branch = options.branch ?? this.config.defaultBranch;
95
+ const resolvedTemplate = this.resolveTemplatePath(template);
96
+ if (this.isLocalPath(resolvedTemplate) && fs.existsSync(resolvedTemplate)) {
97
+ return this.scaffoldFromLocal(resolvedTemplate, options);
98
+ }
99
+ return this.scaffoldFromRemote(resolvedTemplate, branch, options);
100
+ }
101
+ /**
102
+ * Inspect a template without scaffolding.
103
+ * Clones/caches the template and reads its .boilerplate.json configuration
104
+ * without copying any files to an output directory.
105
+ *
106
+ * This is useful for metadata-driven workflows where you need to know
107
+ * the template's type or other configuration before deciding how to handle it.
108
+ *
109
+ * @param options - Inspect options
110
+ * @returns Inspect result with template metadata
111
+ */
112
+ inspect(options) {
113
+ const template = options.template ?? this.config.defaultRepo;
114
+ if (!template) {
115
+ throw new Error('No template specified and no defaultRepo configured. ' +
116
+ 'Either pass template in options or set defaultRepo in config.');
117
+ }
118
+ const branch = options.branch ?? this.config.defaultBranch;
119
+ const resolvedTemplate = this.resolveTemplatePath(template);
120
+ if (this.isLocalPath(resolvedTemplate) && fs.existsSync(resolvedTemplate)) {
121
+ return this.inspectLocal(resolvedTemplate, options.fromPath);
122
+ }
123
+ return this.inspectRemote(resolvedTemplate, branch, options.fromPath);
124
+ }
125
+ /**
126
+ * Read the .boilerplates.json configuration from a template repository root.
127
+ */
128
+ readBoilerplatesConfig(templateDir) {
129
+ const configPath = path.join(templateDir, '.boilerplates.json');
130
+ if (fs.existsSync(configPath)) {
131
+ try {
132
+ const content = fs.readFileSync(configPath, 'utf-8');
133
+ return JSON.parse(content);
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ /**
142
+ * Read the .boilerplate.json configuration from a boilerplate directory.
143
+ */
144
+ readBoilerplateConfig(boilerplatePath) {
145
+ const jsonPath = path.join(boilerplatePath, '.boilerplate.json');
146
+ if (fs.existsSync(jsonPath)) {
147
+ try {
148
+ const content = fs.readFileSync(jsonPath, 'utf-8');
149
+ return JSON.parse(content);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+ /**
158
+ * Get the underlying CacheManager instance for advanced cache operations.
159
+ */
160
+ getCacheManager() {
161
+ return this.cacheManager;
162
+ }
163
+ /**
164
+ * Get the underlying GitCloner instance for advanced git operations.
165
+ */
166
+ getGitCloner() {
167
+ return this.gitCloner;
168
+ }
169
+ /**
170
+ * Get the underlying Templatizer instance for advanced template operations.
171
+ */
172
+ getTemplatizer() {
173
+ return this.templatizer;
174
+ }
175
+ inspectLocal(templateDir, fromPath) {
176
+ const { fromPath: resolvedFromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, fromPath);
177
+ const config = this.readBoilerplateConfig(resolvedTemplatePath);
178
+ return {
179
+ templateDir,
180
+ resolvedFromPath,
181
+ resolvedTemplatePath,
182
+ cacheUsed: false,
183
+ cacheExpired: false,
184
+ config,
185
+ };
186
+ }
187
+ inspectRemote(templateUrl, branch, fromPath) {
188
+ const normalizedUrl = this.gitCloner.normalizeUrl(templateUrl);
189
+ const cacheKey = this.cacheManager.createKey(normalizedUrl, branch);
190
+ const expiredMetadata = this.cacheManager.checkExpiration(cacheKey);
191
+ if (expiredMetadata) {
192
+ this.cacheManager.clear(cacheKey);
193
+ }
194
+ let templateDir;
195
+ let cacheUsed = false;
196
+ const cachedPath = this.cacheManager.get(cacheKey);
197
+ if (cachedPath && !expiredMetadata) {
198
+ templateDir = cachedPath;
199
+ cacheUsed = true;
200
+ }
201
+ else {
202
+ const tempDest = path.join(this.cacheManager.getReposDir(), cacheKey);
203
+ this.gitCloner.clone(normalizedUrl, tempDest, {
204
+ branch,
205
+ depth: 1,
206
+ singleBranch: true,
207
+ });
208
+ this.cacheManager.set(cacheKey, tempDest);
209
+ templateDir = tempDest;
210
+ }
211
+ const { fromPath: resolvedFromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, fromPath);
212
+ const config = this.readBoilerplateConfig(resolvedTemplatePath);
213
+ return {
214
+ templateDir,
215
+ resolvedFromPath,
216
+ resolvedTemplatePath,
217
+ cacheUsed,
218
+ cacheExpired: Boolean(expiredMetadata),
219
+ config,
220
+ };
221
+ }
222
+ async scaffoldFromLocal(templateDir, options) {
223
+ const { fromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, options.fromPath);
224
+ const boilerplateConfig = this.readBoilerplateConfig(resolvedTemplatePath);
225
+ const result = await this.templatizer.process(templateDir, options.outputDir, {
226
+ argv: options.answers,
227
+ noTty: options.noTty,
228
+ fromPath,
229
+ prompter: options.prompter,
230
+ });
231
+ return {
232
+ outputDir: result.outputDir,
233
+ cacheUsed: false,
234
+ cacheExpired: false,
235
+ templateDir,
236
+ fromPath,
237
+ questions: boilerplateConfig?.questions,
238
+ answers: result.answers,
239
+ };
240
+ }
241
+ async scaffoldFromRemote(templateUrl, branch, options) {
242
+ const normalizedUrl = this.gitCloner.normalizeUrl(templateUrl);
243
+ const cacheKey = this.cacheManager.createKey(normalizedUrl, branch);
244
+ const expiredMetadata = this.cacheManager.checkExpiration(cacheKey);
245
+ if (expiredMetadata) {
246
+ this.cacheManager.clear(cacheKey);
247
+ }
248
+ let templateDir;
249
+ let cacheUsed = false;
250
+ const cachedPath = this.cacheManager.get(cacheKey);
251
+ if (cachedPath && !expiredMetadata) {
252
+ templateDir = cachedPath;
253
+ cacheUsed = true;
254
+ }
255
+ else {
256
+ const tempDest = path.join(this.cacheManager.getReposDir(), cacheKey);
257
+ this.gitCloner.clone(normalizedUrl, tempDest, {
258
+ branch,
259
+ depth: 1,
260
+ singleBranch: true,
261
+ });
262
+ this.cacheManager.set(cacheKey, tempDest);
263
+ templateDir = tempDest;
264
+ }
265
+ const { fromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, options.fromPath);
266
+ const boilerplateConfig = this.readBoilerplateConfig(resolvedTemplatePath);
267
+ const result = await this.templatizer.process(templateDir, options.outputDir, {
268
+ argv: options.answers,
269
+ noTty: options.noTty,
270
+ fromPath,
271
+ prompter: options.prompter,
272
+ });
273
+ return {
274
+ outputDir: result.outputDir,
275
+ cacheUsed,
276
+ cacheExpired: Boolean(expiredMetadata),
277
+ templateDir,
278
+ fromPath,
279
+ questions: boilerplateConfig?.questions,
280
+ answers: result.answers,
281
+ };
282
+ }
283
+ /**
284
+ * Resolve the fromPath using .boilerplates.json convention.
285
+ *
286
+ * Resolution order:
287
+ * 1. If explicit fromPath is provided and exists, use it directly
288
+ * 2. If .boilerplates.json exists with a dir field, prepend it to fromPath
289
+ * 3. Return the fromPath as-is
290
+ */
291
+ resolveFromPath(templateDir, fromPath) {
292
+ if (!fromPath) {
293
+ return {
294
+ fromPath: undefined,
295
+ resolvedTemplatePath: templateDir,
296
+ };
297
+ }
298
+ const directPath = path.isAbsolute(fromPath)
299
+ ? fromPath
300
+ : path.join(templateDir, fromPath);
301
+ if (fs.existsSync(directPath) && fs.statSync(directPath).isDirectory()) {
302
+ return {
303
+ fromPath: path.isAbsolute(fromPath) ? path.relative(templateDir, fromPath) : fromPath,
304
+ resolvedTemplatePath: directPath,
305
+ };
306
+ }
307
+ const rootConfig = this.readBoilerplatesConfig(templateDir);
308
+ if (rootConfig?.dir) {
309
+ const configBasedPath = path.join(templateDir, rootConfig.dir, fromPath);
310
+ if (fs.existsSync(configBasedPath) && fs.statSync(configBasedPath).isDirectory()) {
311
+ return {
312
+ fromPath: path.join(rootConfig.dir, fromPath),
313
+ resolvedTemplatePath: configBasedPath,
314
+ };
315
+ }
316
+ }
317
+ return {
318
+ fromPath,
319
+ resolvedTemplatePath: path.join(templateDir, fromPath),
320
+ };
321
+ }
322
+ isLocalPath(value) {
323
+ return (value.startsWith('.') ||
324
+ value.startsWith('/') ||
325
+ value.startsWith('~') ||
326
+ (process.platform === 'win32' && /^[a-zA-Z]:/.test(value)));
327
+ }
328
+ resolveTemplatePath(template) {
329
+ if (this.isLocalPath(template)) {
330
+ if (template.startsWith('~')) {
331
+ const home = process.env.HOME || process.env.USERPROFILE || '';
332
+ return path.join(home, template.slice(1));
333
+ }
334
+ return path.resolve(template);
335
+ }
336
+ return template;
337
+ }
338
+ }
339
+ exports.TemplateScaffolder = TemplateScaffolder;
@@ -0,0 +1,175 @@
1
+ import { Inquirerer } from 'inquirerer';
2
+ import { Question } from 'inquirerer';
3
+ /**
4
+ * Configuration for TemplateScaffolder instance
5
+ */
6
+ export interface TemplateScaffolderConfig {
7
+ /**
8
+ * Tool name used for cache directory naming (e.g., 'my-cli' -> ~/.my-cli/cache)
9
+ */
10
+ toolName: string;
11
+ /**
12
+ * Default template repository URL or path.
13
+ * Used when scaffold() is called without specifying a template.
14
+ */
15
+ defaultRepo?: string;
16
+ /**
17
+ * Default branch to use when cloning repositories
18
+ */
19
+ defaultBranch?: string;
20
+ /**
21
+ * Cache time-to-live in milliseconds.
22
+ * Cached templates older than this will be re-cloned.
23
+ * Default: no expiration
24
+ */
25
+ ttlMs?: number;
26
+ /**
27
+ * Base directory for cache storage.
28
+ * Useful for tests to avoid touching the real home directory.
29
+ */
30
+ cacheBaseDir?: string;
31
+ }
32
+ /**
33
+ * Options for a single scaffold operation
34
+ */
35
+ export interface ScaffoldOptions {
36
+ /**
37
+ * Template repository URL, local path, or org/repo shorthand.
38
+ * If not provided, uses the defaultRepo from config.
39
+ */
40
+ template?: string;
41
+ /**
42
+ * Branch to clone (for remote repositories)
43
+ */
44
+ branch?: string;
45
+ /**
46
+ * Subdirectory within the template repository to use as the template root.
47
+ * Can be a direct path or a variant name that gets resolved via .boilerplates.json
48
+ */
49
+ fromPath?: string;
50
+ /**
51
+ * Output directory for the generated project
52
+ */
53
+ outputDir: string;
54
+ /**
55
+ * Pre-populated answers to skip prompting for known values
56
+ */
57
+ answers?: Record<string, any>;
58
+ /**
59
+ * Disable TTY mode for non-interactive environments
60
+ */
61
+ noTty?: boolean;
62
+ /**
63
+ * Optional Inquirerer instance to reuse for prompting.
64
+ * If provided, the caller retains ownership and is responsible for closing it.
65
+ * If not provided, a new instance will be created and closed automatically.
66
+ */
67
+ prompter?: Inquirerer;
68
+ }
69
+ /**
70
+ * Result of a scaffold operation
71
+ */
72
+ export interface ScaffoldResult {
73
+ /**
74
+ * Path to the generated output directory
75
+ */
76
+ outputDir: string;
77
+ /**
78
+ * Whether a cached template was used
79
+ */
80
+ cacheUsed: boolean;
81
+ /**
82
+ * Whether the cache was expired and refreshed
83
+ */
84
+ cacheExpired: boolean;
85
+ /**
86
+ * Path to the cached/cloned template directory
87
+ */
88
+ templateDir: string;
89
+ /**
90
+ * The resolved fromPath used for template processing
91
+ */
92
+ fromPath?: string;
93
+ /**
94
+ * Questions loaded from .boilerplate.json, if any
95
+ */
96
+ questions?: Question[];
97
+ /**
98
+ * Answers collected during prompting
99
+ */
100
+ answers: Record<string, any>;
101
+ }
102
+ /**
103
+ * Root configuration for a boilerplates repository.
104
+ * Stored in `.boilerplates.json` at the repository root.
105
+ */
106
+ export interface BoilerplatesConfig {
107
+ /**
108
+ * Default directory containing boilerplate templates (e.g., "templates", "boilerplates")
109
+ */
110
+ dir?: string;
111
+ }
112
+ /**
113
+ * Configuration for a single boilerplate template.
114
+ * Stored in `.boilerplate.json` within each template directory.
115
+ */
116
+ export interface BoilerplateConfig {
117
+ /**
118
+ * Optional type identifier for the boilerplate
119
+ */
120
+ type?: string;
121
+ /**
122
+ * Questions to prompt the user during scaffolding
123
+ */
124
+ questions?: Question[];
125
+ }
126
+ /**
127
+ * Options for inspecting a template without scaffolding.
128
+ * Used to read template metadata before deciding how to handle it.
129
+ */
130
+ export interface InspectOptions {
131
+ /**
132
+ * Template repository URL, local path, or org/repo shorthand.
133
+ * If not provided, uses the defaultRepo from config.
134
+ */
135
+ template?: string;
136
+ /**
137
+ * Branch to clone (for remote repositories)
138
+ */
139
+ branch?: string;
140
+ /**
141
+ * Subdirectory within the template repository to inspect.
142
+ * Can be a direct path or a variant name that gets resolved via .boilerplates.json
143
+ */
144
+ fromPath?: string;
145
+ }
146
+ /**
147
+ * Result of inspecting a template.
148
+ * Contains metadata about the template without copying any files.
149
+ */
150
+ export interface InspectResult {
151
+ /**
152
+ * Path to the cached/cloned template directory
153
+ */
154
+ templateDir: string;
155
+ /**
156
+ * The resolved fromPath after .boilerplates.json resolution
157
+ */
158
+ resolvedFromPath?: string;
159
+ /**
160
+ * Full path to the resolved template directory
161
+ */
162
+ resolvedTemplatePath: string;
163
+ /**
164
+ * Whether a cached template was used
165
+ */
166
+ cacheUsed: boolean;
167
+ /**
168
+ * Whether the cache was expired and refreshed
169
+ */
170
+ cacheExpired: boolean;
171
+ /**
172
+ * The .boilerplate.json configuration from the template, if present
173
+ */
174
+ config: BoilerplateConfig | null;
175
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,4 +1,4 @@
1
- import { Question } from 'inquirerer';
1
+ import { Inquirerer, Question } from 'inquirerer';
2
2
  import { ExtractedVariables } from '../types';
3
3
  /**
4
4
  * Generate questions from extracted variables
@@ -10,7 +10,10 @@ export declare function generateQuestions(extractedVariables: ExtractedVariables
10
10
  * Prompt the user for variable values
11
11
  * @param extractedVariables - Variables extracted from the template
12
12
  * @param argv - Command-line arguments to pre-populate answers
13
- * @param noTty - Whether to disable TTY mode
13
+ * @param existingPrompter - Optional existing Inquirerer instance to reuse.
14
+ * If provided, the caller retains ownership and must close it themselves.
15
+ * If not provided, a new instance is created and closed automatically.
16
+ * @param noTty - Whether to disable TTY mode (only used when creating a new prompter)
14
17
  * @returns Answers from the user
15
18
  */
16
- export declare function promptUser(extractedVariables: ExtractedVariables, argv?: Record<string, any>, noTty?: boolean): Promise<Record<string, any>>;
19
+ export declare function promptUser(extractedVariables: ExtractedVariables, argv?: Record<string, any>, existingPrompter?: Inquirerer, noTty?: boolean): Promise<Record<string, any>>;
@@ -58,18 +58,22 @@ function normalizeQuestionName(name) {
58
58
  * Prompt the user for variable values
59
59
  * @param extractedVariables - Variables extracted from the template
60
60
  * @param argv - Command-line arguments to pre-populate answers
61
- * @param noTty - Whether to disable TTY mode
61
+ * @param existingPrompter - Optional existing Inquirerer instance to reuse.
62
+ * If provided, the caller retains ownership and must close it themselves.
63
+ * If not provided, a new instance is created and closed automatically.
64
+ * @param noTty - Whether to disable TTY mode (only used when creating a new prompter)
62
65
  * @returns Answers from the user
63
66
  */
64
- async function promptUser(extractedVariables, argv = {}, noTty = false) {
67
+ async function promptUser(extractedVariables, argv = {}, existingPrompter, noTty = false) {
65
68
  const questions = generateQuestions(extractedVariables);
66
69
  if (questions.length === 0) {
67
70
  return argv;
68
71
  }
69
72
  const preparedArgv = mapArgvToQuestions(argv, questions);
70
- const prompter = new inquirerer_1.Inquirerer({
71
- noTty
72
- });
73
+ // If an existing prompter is provided, use it (caller owns lifecycle)
74
+ // Otherwise, create a new one and close it when done
75
+ const prompter = existingPrompter ?? new inquirerer_1.Inquirerer({ noTty });
76
+ const shouldClose = !existingPrompter;
73
77
  try {
74
78
  const promptAnswers = await prompter.prompt(preparedArgv, questions);
75
79
  return {
@@ -78,7 +82,9 @@ async function promptUser(extractedVariables, argv = {}, noTty = false) {
78
82
  };
79
83
  }
80
84
  finally {
81
- prompter.close();
85
+ if (shouldClose) {
86
+ prompter.close();
87
+ }
82
88
  }
83
89
  }
84
90
  function mapArgvToQuestions(argv, questions) {
@@ -6,7 +6,7 @@ export declare class Templatizer {
6
6
  * Process a local template directory (extract + prompt + replace)
7
7
  * @param templateDir - Local directory path (MUST be local, NOT git URL)
8
8
  * @param outputDir - Output directory for generated project
9
- * @param options - Processing options (argv overrides, noTty)
9
+ * @param options - Processing options (argv overrides, noTty, prompter)
10
10
  * @returns Processing result
11
11
  */
12
12
  process(templateDir: string, outputDir: string, options?: ProcessOptions): Promise<TemplatizerResult>;
@@ -16,8 +16,12 @@ export declare class Templatizer {
16
16
  extract(templateDir: string): Promise<ExtractedVariables>;
17
17
  /**
18
18
  * Prompt user for variables
19
+ * @param extracted - Extracted variables from template
20
+ * @param argv - Pre-populated answers
21
+ * @param prompter - Optional existing Inquirerer instance to reuse
22
+ * @param noTty - Whether to disable TTY mode (only used when creating a new prompter)
19
23
  */
20
- prompt(extracted: ExtractedVariables, argv?: Record<string, any>, noTty?: boolean): Promise<Record<string, any>>;
24
+ prompt(extracted: ExtractedVariables, argv?: Record<string, any>, prompter?: import('inquirerer').Inquirerer, noTty?: boolean): Promise<Record<string, any>>;
21
25
  /**
22
26
  * Replace variables in template
23
27
  */
@@ -47,7 +47,7 @@ class Templatizer {
47
47
  * Process a local template directory (extract + prompt + replace)
48
48
  * @param templateDir - Local directory path (MUST be local, NOT git URL)
49
49
  * @param outputDir - Output directory for generated project
50
- * @param options - Processing options (argv overrides, noTty)
50
+ * @param options - Processing options (argv overrides, noTty, prompter)
51
51
  * @returns Processing result
52
52
  */
53
53
  async process(templateDir, outputDir, options) {
@@ -59,8 +59,8 @@ class Templatizer {
59
59
  this.validateTemplateDir(actualTemplateDir);
60
60
  // Extract variables
61
61
  const variables = await this.extract(actualTemplateDir);
62
- // Prompt for values
63
- const answers = await this.prompt(variables, options?.argv, options?.noTty);
62
+ // Prompt for values (pass through optional prompter)
63
+ const answers = await this.prompt(variables, options?.argv, options?.prompter, options?.noTty);
64
64
  // Replace variables
65
65
  await this.replace(actualTemplateDir, outputDir, variables, answers);
66
66
  return {
@@ -77,9 +77,13 @@ class Templatizer {
77
77
  }
78
78
  /**
79
79
  * Prompt user for variables
80
+ * @param extracted - Extracted variables from template
81
+ * @param argv - Pre-populated answers
82
+ * @param prompter - Optional existing Inquirerer instance to reuse
83
+ * @param noTty - Whether to disable TTY mode (only used when creating a new prompter)
80
84
  */
81
- async prompt(extracted, argv, noTty) {
82
- return (0, prompt_1.promptUser)(extracted, argv ?? {}, noTty ?? false);
85
+ async prompt(extracted, argv, prompter, noTty) {
86
+ return (0, prompt_1.promptUser)(extracted, argv ?? {}, prompter, noTty ?? false);
83
87
  }
84
88
  /**
85
89
  * Replace variables in template
@@ -1,8 +1,15 @@
1
+ import { Inquirerer } from 'inquirerer';
1
2
  import { ExtractedVariables } from '../types';
2
3
  export interface ProcessOptions {
3
4
  argv?: Record<string, any>;
4
5
  noTty?: boolean;
5
6
  fromPath?: string;
7
+ /**
8
+ * Optional Inquirerer instance to reuse for prompting.
9
+ * If provided, the caller retains ownership and is responsible for closing it.
10
+ * If not provided, a new instance will be created and closed automatically.
11
+ */
12
+ prompter?: Inquirerer;
6
13
  }
7
14
  export interface TemplatizerResult {
8
15
  outputDir: string;