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.
- package/README.md +154 -1125
- package/cache/cache-manager.d.ts +60 -0
- package/cache/cache-manager.js +228 -0
- package/cache/types.d.ts +22 -0
- package/esm/cache/cache-manager.js +191 -0
- package/esm/git/git-cloner.js +92 -0
- package/esm/index.js +41 -4
- package/esm/licenses.js +120 -0
- package/esm/scaffolder/index.js +2 -0
- package/esm/scaffolder/template-scaffolder.js +310 -0
- package/esm/scaffolder/types.js +1 -0
- package/esm/template/extract.js +162 -0
- package/esm/template/prompt.js +103 -0
- package/esm/template/replace.js +110 -0
- package/esm/template/templatizer.js +73 -0
- package/esm/template/types.js +1 -0
- package/esm/types.js +1 -0
- package/esm/utils/npm-version-check.js +52 -0
- package/esm/utils/types.js +1 -0
- package/git/git-cloner.d.ts +32 -0
- package/git/git-cloner.js +129 -0
- package/git/types.d.ts +15 -0
- package/index.d.ts +29 -4
- package/index.js +43 -4
- package/licenses-templates/APACHE-2.0.txt +18 -0
- package/licenses-templates/BSD-3-CLAUSE.txt +28 -0
- package/licenses-templates/CLOSED.txt +20 -0
- package/licenses-templates/GPL-3.0.txt +18 -0
- package/licenses-templates/ISC.txt +16 -0
- package/licenses-templates/MIT.txt +22 -0
- package/licenses-templates/MPL-2.0.txt +8 -0
- package/licenses-templates/UNLICENSE.txt +22 -0
- package/licenses.d.ts +18 -0
- package/licenses.js +162 -0
- package/package.json +9 -14
- package/scaffolder/index.d.ts +2 -0
- package/{question → scaffolder}/index.js +1 -0
- package/scaffolder/template-scaffolder.d.ts +91 -0
- package/scaffolder/template-scaffolder.js +347 -0
- package/scaffolder/types.d.ts +191 -0
- package/scaffolder/types.js +2 -0
- package/template/extract.d.ts +7 -0
- package/template/extract.js +198 -0
- package/template/prompt.d.ts +19 -0
- package/template/prompt.js +107 -0
- package/template/replace.d.ts +9 -0
- package/template/replace.js +146 -0
- package/template/templatizer.d.ts +33 -0
- package/template/templatizer.js +110 -0
- package/template/types.d.ts +18 -0
- package/template/types.js +2 -0
- package/types.d.ts +99 -0
- package/types.js +2 -0
- package/utils/npm-version-check.d.ts +17 -0
- package/utils/npm-version-check.js +57 -0
- package/utils/types.d.ts +6 -0
- package/utils/types.js +2 -0
- package/commander.d.ts +0 -21
- package/commander.js +0 -57
- package/esm/commander.js +0 -50
- package/esm/keypress.js +0 -95
- package/esm/prompt.js +0 -1024
- package/esm/question/index.js +0 -1
- package/esm/resolvers/date.js +0 -11
- package/esm/resolvers/git.js +0 -26
- package/esm/resolvers/index.js +0 -103
- package/esm/resolvers/npm.js +0 -24
- package/esm/resolvers/workspace.js +0 -141
- package/esm/utils.js +0 -12
- package/keypress.d.ts +0 -45
- package/keypress.js +0 -99
- package/prompt.d.ts +0 -116
- package/prompt.js +0 -1032
- package/question/index.d.ts +0 -1
- package/question/types.d.ts +0 -65
- package/resolvers/date.d.ts +0 -5
- package/resolvers/date.js +0 -14
- package/resolvers/git.d.ts +0 -11
- package/resolvers/git.js +0 -30
- package/resolvers/index.d.ts +0 -63
- package/resolvers/index.js +0 -111
- package/resolvers/npm.d.ts +0 -10
- package/resolvers/npm.js +0 -28
- package/resolvers/types.d.ts +0 -12
- package/resolvers/workspace.d.ts +0 -6
- package/resolvers/workspace.js +0 -144
- package/utils.d.ts +0 -2
- package/utils.js +0 -16
- /package/{question → cache}/types.js +0 -0
- /package/esm/{question → cache}/types.js +0 -0
- /package/esm/{resolvers → git}/types.js +0 -0
- /package/{resolvers → git}/types.js +0 -0
package/esm/licenses.js
ADDED
|
@@ -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,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
|
+
}
|