genomic 4.0.2 → 5.0.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.
Files changed (92) hide show
  1. package/README.md +154 -1125
  2. package/cache/cache-manager.d.ts +60 -0
  3. package/cache/cache-manager.js +228 -0
  4. package/cache/types.d.ts +22 -0
  5. package/esm/cache/cache-manager.js +191 -0
  6. package/esm/git/git-cloner.js +92 -0
  7. package/esm/index.js +41 -4
  8. package/esm/licenses.js +120 -0
  9. package/esm/scaffolder/index.js +2 -0
  10. package/esm/scaffolder/template-scaffolder.js +310 -0
  11. package/esm/scaffolder/types.js +1 -0
  12. package/esm/template/extract.js +162 -0
  13. package/esm/template/prompt.js +103 -0
  14. package/esm/template/replace.js +110 -0
  15. package/esm/template/templatizer.js +73 -0
  16. package/esm/template/types.js +1 -0
  17. package/esm/types.js +1 -0
  18. package/esm/utils/npm-version-check.js +52 -0
  19. package/esm/utils/types.js +1 -0
  20. package/git/git-cloner.d.ts +32 -0
  21. package/git/git-cloner.js +129 -0
  22. package/git/types.d.ts +15 -0
  23. package/index.d.ts +29 -4
  24. package/index.js +43 -4
  25. package/licenses-templates/APACHE-2.0.txt +18 -0
  26. package/licenses-templates/BSD-3-CLAUSE.txt +28 -0
  27. package/licenses-templates/CLOSED.txt +20 -0
  28. package/licenses-templates/GPL-3.0.txt +18 -0
  29. package/licenses-templates/ISC.txt +16 -0
  30. package/licenses-templates/MIT.txt +22 -0
  31. package/licenses-templates/MPL-2.0.txt +8 -0
  32. package/licenses-templates/UNLICENSE.txt +22 -0
  33. package/licenses.d.ts +18 -0
  34. package/licenses.js +162 -0
  35. package/package.json +9 -14
  36. package/scaffolder/index.d.ts +2 -0
  37. package/{question → scaffolder}/index.js +1 -0
  38. package/scaffolder/template-scaffolder.d.ts +91 -0
  39. package/scaffolder/template-scaffolder.js +347 -0
  40. package/scaffolder/types.d.ts +191 -0
  41. package/scaffolder/types.js +2 -0
  42. package/template/extract.d.ts +7 -0
  43. package/template/extract.js +198 -0
  44. package/template/prompt.d.ts +19 -0
  45. package/template/prompt.js +107 -0
  46. package/template/replace.d.ts +9 -0
  47. package/template/replace.js +146 -0
  48. package/template/templatizer.d.ts +33 -0
  49. package/template/templatizer.js +110 -0
  50. package/template/types.d.ts +18 -0
  51. package/template/types.js +2 -0
  52. package/types.d.ts +99 -0
  53. package/types.js +2 -0
  54. package/utils/npm-version-check.d.ts +17 -0
  55. package/utils/npm-version-check.js +57 -0
  56. package/utils/types.d.ts +6 -0
  57. package/utils/types.js +2 -0
  58. package/commander.d.ts +0 -21
  59. package/commander.js +0 -57
  60. package/esm/commander.js +0 -50
  61. package/esm/keypress.js +0 -95
  62. package/esm/prompt.js +0 -1024
  63. package/esm/question/index.js +0 -1
  64. package/esm/resolvers/date.js +0 -11
  65. package/esm/resolvers/git.js +0 -26
  66. package/esm/resolvers/index.js +0 -103
  67. package/esm/resolvers/npm.js +0 -24
  68. package/esm/resolvers/workspace.js +0 -141
  69. package/esm/utils.js +0 -12
  70. package/keypress.d.ts +0 -45
  71. package/keypress.js +0 -99
  72. package/prompt.d.ts +0 -116
  73. package/prompt.js +0 -1032
  74. package/question/index.d.ts +0 -1
  75. package/question/types.d.ts +0 -65
  76. package/resolvers/date.d.ts +0 -5
  77. package/resolvers/date.js +0 -14
  78. package/resolvers/git.d.ts +0 -11
  79. package/resolvers/git.js +0 -30
  80. package/resolvers/index.d.ts +0 -63
  81. package/resolvers/index.js +0 -111
  82. package/resolvers/npm.d.ts +0 -10
  83. package/resolvers/npm.js +0 -28
  84. package/resolvers/types.d.ts +0 -12
  85. package/resolvers/workspace.d.ts +0 -6
  86. package/resolvers/workspace.js +0 -144
  87. package/utils.d.ts +0 -2
  88. package/utils.js +0 -16
  89. /package/{question → cache}/types.js +0 -0
  90. /package/esm/{question → cache}/types.js +0 -0
  91. /package/esm/{resolvers → git}/types.js +0 -0
  92. /package/{resolvers → git}/types.js +0 -0
@@ -0,0 +1,120 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ const PLACEHOLDER_PATTERN = /{{(\w+)}}/g;
4
+ let cachedTemplates = null;
5
+ export const DEFAULT_LICENSE = 'MIT';
6
+ export const CLOSED_LICENSE = 'CLOSED';
7
+ export const LICENSE_VALUE_KEYS = ['LICENSE', 'license'];
8
+ export const LICENSE_AUTHOR_KEYS = [
9
+ 'USERFULLNAME',
10
+ 'AUTHOR',
11
+ 'AUTHORFULLNAME',
12
+ 'USERNAME',
13
+ 'fullName',
14
+ 'author',
15
+ 'authorFullName',
16
+ 'userName',
17
+ ];
18
+ export const LICENSE_EMAIL_KEYS = ['USEREMAIL', 'EMAIL', 'email', 'userEmail'];
19
+ export function isSupportedLicense(name) {
20
+ if (!name) {
21
+ return false;
22
+ }
23
+ return Boolean(loadLicenseTemplates()[name.toUpperCase()]);
24
+ }
25
+ export function renderLicense(licenseName, context) {
26
+ if (!licenseName) {
27
+ return null;
28
+ }
29
+ const templates = loadLicenseTemplates();
30
+ const template = templates[licenseName.toUpperCase()];
31
+ if (!template) {
32
+ return null;
33
+ }
34
+ const ctx = {
35
+ year: context.year ?? new Date().getFullYear().toString(),
36
+ author: context.author ?? 'Unknown Author',
37
+ email: context.email ?? '',
38
+ };
39
+ const emailLine = ctx.email ? ` <${ctx.email}>` : '';
40
+ return template.replace(PLACEHOLDER_PATTERN, (_, rawKey) => {
41
+ const key = rawKey.toUpperCase();
42
+ if (key === 'EMAIL_LINE') {
43
+ return emailLine;
44
+ }
45
+ const normalizedKey = key.toLowerCase();
46
+ const value = ctx[normalizedKey];
47
+ return value || '';
48
+ });
49
+ }
50
+ export function listSupportedLicenses() {
51
+ const licenses = Object.keys(loadLicenseTemplates());
52
+ // Sort with DEFAULT_LICENSE first, CLOSED_LICENSE last, then alphabetically
53
+ return licenses.sort((a, b) => {
54
+ if (a === DEFAULT_LICENSE)
55
+ return -1;
56
+ if (b === DEFAULT_LICENSE)
57
+ return 1;
58
+ if (a === CLOSED_LICENSE)
59
+ return 1;
60
+ if (b === CLOSED_LICENSE)
61
+ return -1;
62
+ return a.localeCompare(b);
63
+ });
64
+ }
65
+ export function findLicenseValue(answers) {
66
+ return getAnswerValue(answers, LICENSE_VALUE_KEYS);
67
+ }
68
+ export function findLicenseAuthor(answers) {
69
+ return getAnswerValue(answers, LICENSE_AUTHOR_KEYS);
70
+ }
71
+ export function findLicenseEmail(answers) {
72
+ return getAnswerValue(answers, LICENSE_EMAIL_KEYS);
73
+ }
74
+ function loadLicenseTemplates() {
75
+ if (cachedTemplates) {
76
+ return cachedTemplates;
77
+ }
78
+ const dir = findTemplatesDir();
79
+ if (!dir) {
80
+ cachedTemplates = {};
81
+ return cachedTemplates;
82
+ }
83
+ const files = fs.readdirSync(dir);
84
+ const templates = {};
85
+ for (const file of files) {
86
+ const fullPath = path.join(dir, file);
87
+ const stats = fs.statSync(fullPath);
88
+ if (!stats.isFile()) {
89
+ continue;
90
+ }
91
+ if (path.extname(file).toLowerCase() !== '.txt') {
92
+ continue;
93
+ }
94
+ const key = path.basename(file, path.extname(file)).toUpperCase();
95
+ templates[key] = fs.readFileSync(fullPath, 'utf8');
96
+ }
97
+ cachedTemplates = templates;
98
+ return cachedTemplates;
99
+ }
100
+ function findTemplatesDir() {
101
+ const candidates = [
102
+ path.resolve(__dirname, '..', 'licenses-templates'),
103
+ path.resolve(__dirname, 'licenses-templates'),
104
+ ];
105
+ for (const candidate of candidates) {
106
+ if (fs.existsSync(candidate)) {
107
+ return candidate;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ function getAnswerValue(answers, keys) {
113
+ for (const key of keys) {
114
+ const value = answers?.[key];
115
+ if (typeof value === 'string' && value.trim() !== '') {
116
+ return value;
117
+ }
118
+ }
119
+ return undefined;
120
+ }
@@ -0,0 +1,2 @@
1
+ export * from './template-scaffolder';
2
+ export * from './types';
@@ -0,0 +1,310 @@
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
+ const useBoilerplatesConfig = options.useBoilerplatesConfig ?? true;
85
+ if (this.isLocalPath(resolvedTemplate) && fs.existsSync(resolvedTemplate)) {
86
+ return this.inspectLocal(resolvedTemplate, options.fromPath, useBoilerplatesConfig);
87
+ }
88
+ return this.inspectRemote(resolvedTemplate, branch, options.fromPath, useBoilerplatesConfig);
89
+ }
90
+ /**
91
+ * Read the .boilerplates.json configuration from a template repository root.
92
+ */
93
+ readBoilerplatesConfig(templateDir) {
94
+ const configPath = path.join(templateDir, '.boilerplates.json');
95
+ if (fs.existsSync(configPath)) {
96
+ try {
97
+ const content = fs.readFileSync(configPath, 'utf-8');
98
+ return JSON.parse(content);
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+ /**
107
+ * Read the .boilerplate.json configuration from a boilerplate directory.
108
+ */
109
+ readBoilerplateConfig(boilerplatePath) {
110
+ const jsonPath = path.join(boilerplatePath, '.boilerplate.json');
111
+ if (fs.existsSync(jsonPath)) {
112
+ try {
113
+ const content = fs.readFileSync(jsonPath, 'utf-8');
114
+ return JSON.parse(content);
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ /**
123
+ * Get the underlying CacheManager instance for advanced cache operations.
124
+ */
125
+ getCacheManager() {
126
+ return this.cacheManager;
127
+ }
128
+ /**
129
+ * Get the underlying GitCloner instance for advanced git operations.
130
+ */
131
+ getGitCloner() {
132
+ return this.gitCloner;
133
+ }
134
+ /**
135
+ * Get the underlying Templatizer instance for advanced template operations.
136
+ */
137
+ getTemplatizer() {
138
+ return this.templatizer;
139
+ }
140
+ inspectLocal(templateDir, fromPath, useBoilerplatesConfig = true) {
141
+ const { fromPath: resolvedFromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, fromPath, useBoilerplatesConfig);
142
+ const config = this.readBoilerplateConfig(resolvedTemplatePath);
143
+ return {
144
+ templateDir,
145
+ resolvedFromPath,
146
+ resolvedTemplatePath,
147
+ cacheUsed: false,
148
+ cacheExpired: false,
149
+ config,
150
+ };
151
+ }
152
+ inspectRemote(templateUrl, branch, fromPath, useBoilerplatesConfig = true) {
153
+ const normalizedUrl = this.gitCloner.normalizeUrl(templateUrl);
154
+ const cacheKey = this.cacheManager.createKey(normalizedUrl, branch);
155
+ const expiredMetadata = this.cacheManager.checkExpiration(cacheKey);
156
+ if (expiredMetadata) {
157
+ this.cacheManager.clear(cacheKey);
158
+ }
159
+ let templateDir;
160
+ let cacheUsed = false;
161
+ const cachedPath = this.cacheManager.get(cacheKey);
162
+ if (cachedPath && !expiredMetadata) {
163
+ templateDir = cachedPath;
164
+ cacheUsed = true;
165
+ }
166
+ else {
167
+ const tempDest = path.join(this.cacheManager.getReposDir(), cacheKey);
168
+ this.gitCloner.clone(normalizedUrl, tempDest, {
169
+ branch,
170
+ depth: 1,
171
+ singleBranch: true,
172
+ });
173
+ this.cacheManager.set(cacheKey, tempDest);
174
+ templateDir = tempDest;
175
+ }
176
+ const { fromPath: resolvedFromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, fromPath, useBoilerplatesConfig);
177
+ const config = this.readBoilerplateConfig(resolvedTemplatePath);
178
+ return {
179
+ templateDir,
180
+ resolvedFromPath,
181
+ resolvedTemplatePath,
182
+ cacheUsed,
183
+ cacheExpired: Boolean(expiredMetadata),
184
+ config,
185
+ };
186
+ }
187
+ async scaffoldFromLocal(templateDir, options) {
188
+ const { fromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, options.fromPath, options.useBoilerplatesConfig ?? true);
189
+ const boilerplateConfig = this.readBoilerplateConfig(resolvedTemplatePath);
190
+ const result = await this.templatizer.process(templateDir, options.outputDir, {
191
+ argv: options.answers,
192
+ noTty: options.noTty,
193
+ fromPath,
194
+ prompter: options.prompter,
195
+ });
196
+ return {
197
+ outputDir: result.outputDir,
198
+ cacheUsed: false,
199
+ cacheExpired: false,
200
+ templateDir,
201
+ fromPath,
202
+ questions: boilerplateConfig?.questions,
203
+ answers: result.answers,
204
+ };
205
+ }
206
+ async scaffoldFromRemote(templateUrl, branch, options) {
207
+ const normalizedUrl = this.gitCloner.normalizeUrl(templateUrl);
208
+ const cacheKey = this.cacheManager.createKey(normalizedUrl, branch);
209
+ const expiredMetadata = this.cacheManager.checkExpiration(cacheKey);
210
+ if (expiredMetadata) {
211
+ this.cacheManager.clear(cacheKey);
212
+ }
213
+ let templateDir;
214
+ let cacheUsed = false;
215
+ const cachedPath = this.cacheManager.get(cacheKey);
216
+ if (cachedPath && !expiredMetadata) {
217
+ templateDir = cachedPath;
218
+ cacheUsed = true;
219
+ }
220
+ else {
221
+ const tempDest = path.join(this.cacheManager.getReposDir(), cacheKey);
222
+ this.gitCloner.clone(normalizedUrl, tempDest, {
223
+ branch,
224
+ depth: 1,
225
+ singleBranch: true,
226
+ });
227
+ this.cacheManager.set(cacheKey, tempDest);
228
+ templateDir = tempDest;
229
+ }
230
+ const { fromPath, resolvedTemplatePath } = this.resolveFromPath(templateDir, options.fromPath, options.useBoilerplatesConfig ?? true);
231
+ const boilerplateConfig = this.readBoilerplateConfig(resolvedTemplatePath);
232
+ const result = await this.templatizer.process(templateDir, options.outputDir, {
233
+ argv: options.answers,
234
+ noTty: options.noTty,
235
+ fromPath,
236
+ prompter: options.prompter,
237
+ });
238
+ return {
239
+ outputDir: result.outputDir,
240
+ cacheUsed,
241
+ cacheExpired: Boolean(expiredMetadata),
242
+ templateDir,
243
+ fromPath,
244
+ questions: boilerplateConfig?.questions,
245
+ answers: result.answers,
246
+ };
247
+ }
248
+ /**
249
+ * Resolve the fromPath using .boilerplates.json convention.
250
+ *
251
+ * Resolution order:
252
+ * 1. If explicit fromPath is provided and exists, use it directly
253
+ * 2. If useBoilerplatesConfig is true and .boilerplates.json exists with a dir field, prepend it to fromPath
254
+ * 3. Return the fromPath as-is
255
+ *
256
+ * @param templateDir - The template repository root directory
257
+ * @param fromPath - The subdirectory path to resolve
258
+ * @param useBoilerplatesConfig - Whether to use .boilerplates.json for fallback resolution (default: true)
259
+ */
260
+ resolveFromPath(templateDir, fromPath, useBoilerplatesConfig = true) {
261
+ if (!fromPath) {
262
+ return {
263
+ fromPath: undefined,
264
+ resolvedTemplatePath: templateDir,
265
+ };
266
+ }
267
+ const directPath = path.isAbsolute(fromPath)
268
+ ? fromPath
269
+ : path.join(templateDir, fromPath);
270
+ if (fs.existsSync(directPath) && fs.statSync(directPath).isDirectory()) {
271
+ return {
272
+ fromPath: path.isAbsolute(fromPath) ? path.relative(templateDir, fromPath) : fromPath,
273
+ resolvedTemplatePath: directPath,
274
+ };
275
+ }
276
+ // Only check .boilerplates.json if useBoilerplatesConfig is true
277
+ if (useBoilerplatesConfig) {
278
+ const rootConfig = this.readBoilerplatesConfig(templateDir);
279
+ if (rootConfig?.dir) {
280
+ const configBasedPath = path.join(templateDir, rootConfig.dir, fromPath);
281
+ if (fs.existsSync(configBasedPath) && fs.statSync(configBasedPath).isDirectory()) {
282
+ return {
283
+ fromPath: path.join(rootConfig.dir, fromPath),
284
+ resolvedTemplatePath: configBasedPath,
285
+ };
286
+ }
287
+ }
288
+ }
289
+ return {
290
+ fromPath,
291
+ resolvedTemplatePath: path.join(templateDir, fromPath),
292
+ };
293
+ }
294
+ isLocalPath(value) {
295
+ return (value.startsWith('.') ||
296
+ value.startsWith('/') ||
297
+ value.startsWith('~') ||
298
+ (process.platform === 'win32' && /^[a-zA-Z]:/.test(value)));
299
+ }
300
+ resolveTemplatePath(template) {
301
+ if (this.isLocalPath(template)) {
302
+ if (template.startsWith('~')) {
303
+ const home = process.env.HOME || process.env.USERPROFILE || '';
304
+ return path.join(home, template.slice(1));
305
+ }
306
+ return path.resolve(template);
307
+ }
308
+ return template;
309
+ }
310
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,162 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ const PLACEHOLDER_BOUNDARY = '____';
4
+ /**
5
+ * Pattern to match ____variable____ in filenames and content
6
+ */
7
+ const VARIABLE_PATTERN = new RegExp(`${PLACEHOLDER_BOUNDARY}([A-Za-z_][A-Za-z0-9_]*)${PLACEHOLDER_BOUNDARY}`, 'g');
8
+ /**
9
+ * Extract all variables from a template directory
10
+ * @param templateDir - Path to the template directory
11
+ * @returns Extracted variables including file replacers, content replacers, and project questions
12
+ */
13
+ export async function extractVariables(templateDir) {
14
+ const fileReplacers = [];
15
+ const contentReplacers = [];
16
+ const fileReplacerVars = new Set();
17
+ const contentReplacerVars = new Set();
18
+ const projectQuestions = await loadProjectQuestions(templateDir);
19
+ await walkDirectory(templateDir, async (filePath) => {
20
+ const relativePath = path.relative(templateDir, filePath);
21
+ // Skip boilerplate configuration files from variable extraction
22
+ if (relativePath === '.boilerplate.json' ||
23
+ relativePath === '.boilerplate.js' ||
24
+ relativePath === '.boilerplates.json') {
25
+ return;
26
+ }
27
+ const matches = relativePath.matchAll(VARIABLE_PATTERN);
28
+ for (const match of matches) {
29
+ const varName = match[1];
30
+ if (!fileReplacerVars.has(varName)) {
31
+ fileReplacerVars.add(varName);
32
+ fileReplacers.push({
33
+ variable: varName,
34
+ pattern: new RegExp(`${PLACEHOLDER_BOUNDARY}${varName}${PLACEHOLDER_BOUNDARY}`, 'g'),
35
+ });
36
+ }
37
+ }
38
+ const contentVars = await extractFromFileContent(filePath);
39
+ for (const varName of contentVars) {
40
+ if (!contentReplacerVars.has(varName)) {
41
+ contentReplacerVars.add(varName);
42
+ contentReplacers.push({
43
+ variable: varName,
44
+ pattern: new RegExp(`${PLACEHOLDER_BOUNDARY}${varName}${PLACEHOLDER_BOUNDARY}`, 'g'),
45
+ });
46
+ }
47
+ }
48
+ });
49
+ return {
50
+ fileReplacers,
51
+ contentReplacers,
52
+ projectQuestions,
53
+ };
54
+ }
55
+ /**
56
+ * Extract variables from file content using streams
57
+ * @param filePath - Path to the file
58
+ * @returns Set of variable names found in the file
59
+ */
60
+ async function extractFromFileContent(filePath) {
61
+ const variables = new Set();
62
+ return new Promise((resolve) => {
63
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
64
+ let buffer = '';
65
+ stream.on('data', (chunk) => {
66
+ buffer += chunk.toString();
67
+ const lines = buffer.split('\n');
68
+ buffer = lines.pop() || ''; // Keep the last incomplete line in buffer
69
+ for (const line of lines) {
70
+ const matches = line.matchAll(VARIABLE_PATTERN);
71
+ for (const match of matches) {
72
+ variables.add(match[1]);
73
+ }
74
+ }
75
+ });
76
+ stream.on('end', () => {
77
+ const matches = buffer.matchAll(VARIABLE_PATTERN);
78
+ for (const match of matches) {
79
+ variables.add(match[1]);
80
+ }
81
+ resolve(variables);
82
+ });
83
+ stream.on('error', () => {
84
+ resolve(variables);
85
+ });
86
+ });
87
+ }
88
+ /**
89
+ * Walk through a directory recursively
90
+ * @param dir - Directory to walk
91
+ * @param callback - Callback function for each file
92
+ */
93
+ async function walkDirectory(dir, callback) {
94
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
95
+ for (const entry of entries) {
96
+ const fullPath = path.join(dir, entry.name);
97
+ if (entry.isDirectory()) {
98
+ await walkDirectory(fullPath, callback);
99
+ }
100
+ else if (entry.isFile()) {
101
+ await callback(fullPath);
102
+ }
103
+ }
104
+ }
105
+ /**
106
+ * Load project questions from .boilerplate.json or .boilerplate.js
107
+ * @param templateDir - Path to the template directory
108
+ * @returns Questions object or null if not found
109
+ */
110
+ async function loadProjectQuestions(templateDir) {
111
+ const jsonPath = path.join(templateDir, '.boilerplate.json');
112
+ if (fs.existsSync(jsonPath)) {
113
+ try {
114
+ const content = fs.readFileSync(jsonPath, 'utf8');
115
+ const parsed = JSON.parse(content);
116
+ // .boilerplate.json has { questions: [...] } structure
117
+ const questions = parsed.questions ? { questions: parsed.questions } : parsed;
118
+ return validateQuestions(questions) ? normalizeQuestions(questions) : null;
119
+ }
120
+ catch (error) {
121
+ const errorMessage = error instanceof Error ? error.message : String(error);
122
+ console.warn(`Failed to parse .boilerplate.json: ${errorMessage}`);
123
+ }
124
+ }
125
+ const jsPath = path.join(templateDir, '.boilerplate.js');
126
+ if (fs.existsSync(jsPath)) {
127
+ try {
128
+ const module = require(jsPath);
129
+ const exported = module.default || module;
130
+ // .boilerplate.js can export { questions: [...] } or just the questions array
131
+ const questions = exported.questions ? { questions: exported.questions } : exported;
132
+ return validateQuestions(questions) ? normalizeQuestions(questions) : null;
133
+ }
134
+ catch (error) {
135
+ const errorMessage = error instanceof Error ? error.message : String(error);
136
+ console.warn(`Failed to load .boilerplate.js: ${errorMessage}`);
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ /**
142
+ * Normalize questions by ensuring all required fields have default values
143
+ * @param questions - Questions object to normalize
144
+ * @returns Normalized questions object
145
+ */
146
+ function normalizeQuestions(questions) {
147
+ return {
148
+ ...questions,
149
+ questions: questions.questions.map((q) => ({
150
+ ...q,
151
+ type: q.type || 'text'
152
+ }))
153
+ };
154
+ }
155
+ /**
156
+ * Validate that the questions object has the correct structure
157
+ * @param obj - Object to validate
158
+ * @returns True if valid, false otherwise
159
+ */
160
+ function validateQuestions(obj) {
161
+ return obj && typeof obj === 'object' && Array.isArray(obj.questions);
162
+ }
@@ -0,0 +1,103 @@
1
+ import { Inquirerer } from 'inquirerer';
2
+ const PLACEHOLDER_BOUNDARY = '____';
3
+ /**
4
+ * Generate questions from extracted variables
5
+ * @param extractedVariables - Variables extracted from the template
6
+ * @returns Array of questions to prompt the user
7
+ */
8
+ export function generateQuestions(extractedVariables) {
9
+ const questions = [];
10
+ const askedVariables = new Set();
11
+ if (extractedVariables.projectQuestions) {
12
+ for (const question of extractedVariables.projectQuestions.questions) {
13
+ const normalizedName = normalizeQuestionName(question.name);
14
+ question.name = normalizedName;
15
+ questions.push(question);
16
+ askedVariables.add(normalizedName);
17
+ }
18
+ }
19
+ for (const replacer of extractedVariables.fileReplacers) {
20
+ if (!askedVariables.has(replacer.variable)) {
21
+ questions.push({
22
+ name: replacer.variable,
23
+ type: 'text',
24
+ message: `Enter value for ${replacer.variable}:`,
25
+ required: true
26
+ });
27
+ askedVariables.add(replacer.variable);
28
+ }
29
+ }
30
+ for (const replacer of extractedVariables.contentReplacers) {
31
+ if (!askedVariables.has(replacer.variable)) {
32
+ questions.push({
33
+ name: replacer.variable,
34
+ type: 'text',
35
+ message: `Enter value for ${replacer.variable}:`,
36
+ required: true
37
+ });
38
+ askedVariables.add(replacer.variable);
39
+ }
40
+ }
41
+ return questions;
42
+ }
43
+ function normalizeQuestionName(name) {
44
+ if (name.startsWith(PLACEHOLDER_BOUNDARY) &&
45
+ name.endsWith(PLACEHOLDER_BOUNDARY)) {
46
+ return name.slice(PLACEHOLDER_BOUNDARY.length, -PLACEHOLDER_BOUNDARY.length);
47
+ }
48
+ if (name.startsWith('__') && name.endsWith('__')) {
49
+ return name.slice(2, -2);
50
+ }
51
+ return name;
52
+ }
53
+ /**
54
+ * Prompt the user for variable values
55
+ * @param extractedVariables - Variables extracted from the template
56
+ * @param argv - Command-line arguments to pre-populate answers
57
+ * @param existingPrompter - Optional existing Prompter 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)
61
+ * @returns Answers from the user
62
+ */
63
+ export async function promptUser(extractedVariables, argv = {}, existingPrompter, noTty = false) {
64
+ const questions = generateQuestions(extractedVariables);
65
+ if (questions.length === 0) {
66
+ return argv;
67
+ }
68
+ const preparedArgv = mapArgvToQuestions(argv, questions);
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;
73
+ try {
74
+ const promptAnswers = await prompter.prompt(preparedArgv, questions);
75
+ return {
76
+ ...argv,
77
+ ...promptAnswers,
78
+ };
79
+ }
80
+ finally {
81
+ if (shouldClose) {
82
+ prompter.close();
83
+ }
84
+ }
85
+ }
86
+ function mapArgvToQuestions(argv, questions) {
87
+ if (!questions.length) {
88
+ return argv;
89
+ }
90
+ const prepared = { ...argv };
91
+ const argvEntries = Object.entries(argv);
92
+ for (const question of questions) {
93
+ const name = question.name;
94
+ if (prepared[name] !== undefined) {
95
+ continue;
96
+ }
97
+ const match = argvEntries.find(([key]) => key === name);
98
+ if (match) {
99
+ prepared[name] = match[1];
100
+ }
101
+ }
102
+ return prepared;
103
+ }