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 +78 -6
- package/esm/index.js +2 -0
- package/esm/scaffolder/index.js +2 -0
- package/esm/scaffolder/template-scaffolder.js +302 -0
- package/esm/scaffolder/types.js +1 -0
- package/esm/template/prompt.js +12 -6
- package/esm/template/templatizer.js +9 -5
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/package.json +2 -2
- package/scaffolder/index.d.ts +2 -0
- package/scaffolder/index.js +18 -0
- package/scaffolder/template-scaffolder.d.ts +87 -0
- package/scaffolder/template-scaffolder.js +339 -0
- package/scaffolder/types.d.ts +175 -0
- package/scaffolder/types.js +2 -0
- package/template/prompt.d.ts +6 -3
- package/template/prompt.js +12 -6
- package/template/templatizer.d.ts +6 -2
- package/template/templatizer.js +9 -5
- package/template/types.d.ts +7 -0
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
|
|
40
|
+
`create-gen-app` provides both a high-level orchestrator and modular building blocks for template scaffolding.
|
|
41
41
|
|
|
42
|
-
###
|
|
42
|
+
### Quick Start with TemplateScaffolder
|
|
43
43
|
|
|
44
|
-
-
|
|
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
|
-
|
|
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,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 {};
|
package/esm/template/prompt.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
39
|
+
"gitHead": "9ab0a7a8b90ccedd5f9bbde7dcdaef424c7f5acd"
|
|
40
40
|
}
|
|
@@ -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
|
+
}
|
package/template/prompt.d.ts
CHANGED
|
@@ -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
|
|
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>>;
|
package/template/prompt.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/template/templatizer.js
CHANGED
|
@@ -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
|
package/template/types.d.ts
CHANGED
|
@@ -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;
|