@garyr/pt-cli 1.1.0 → 1.3.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/CHANGELOG.md +47 -0
- package/README.md +11 -6
- package/dist/commands/completionCommand.js +7 -16
- package/dist/commands/initCommand.js +476 -181
- package/dist/commands/learnCommand.js +28 -7
- package/dist/commands/template-utils.js +17 -7
- package/dist/commands/updateCommand.js +56 -25
- package/dist/config.js +3 -0
- package/dist/index.js +23 -5
- package/dist/substitute.js +45 -19
- package/doc/configuration.md +7 -5
- package/doc/usage.md +22 -5
- package/package.json +4 -4
- package/skills/agency-pt-operator/SKILL.md +5 -4
- package/src/commands/completionCommand.ts +7 -15
- package/src/commands/initCommand.ts +495 -194
- package/src/commands/learnCommand.ts +29 -7
- package/src/commands/template-utils.ts +17 -8
- package/src/commands/updateCommand.ts +125 -91
- package/src/config.ts +3 -0
- package/src/index.ts +21 -5
- package/src/substitute.ts +48 -21
- package/tests/config-utils.test.ts +1 -1
- package/tests/learn.test.ts +5 -4
- package/tests/modularity.test.ts +605 -0
- package/tests/update.test.ts +63 -9
|
@@ -3,21 +3,84 @@ import path from 'path';
|
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
4
|
import { loadConfig, sanitizePath } from '../config.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
-
import { processCopyFiles } from '../substitute.js';
|
|
6
|
+
import { processCopyFiles, substituteVariables } from '../substitute.js';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
|
+
/**
|
|
9
|
+
* Recursively merges two arrays of FolderNodes, deduplicating matching folder names.
|
|
10
|
+
* Sub-children are recursively merged, and later nodes take precedence for info/is_file.
|
|
11
|
+
*/
|
|
12
|
+
export function mergeFolderNodes(nodesA, nodesB) {
|
|
13
|
+
const map = new Map();
|
|
14
|
+
function cloneNode(node) {
|
|
15
|
+
return {
|
|
16
|
+
name: node.name,
|
|
17
|
+
info: node.info,
|
|
18
|
+
is_file: node.is_file,
|
|
19
|
+
children: node.children ? node.children.map(cloneNode) : undefined
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
for (const node of nodesA) {
|
|
23
|
+
map.set(node.name, cloneNode(node));
|
|
24
|
+
}
|
|
25
|
+
for (const node of nodesB) {
|
|
26
|
+
if (map.has(node.name)) {
|
|
27
|
+
const existing = map.get(node.name);
|
|
28
|
+
if (node.info) {
|
|
29
|
+
existing.info = node.info;
|
|
30
|
+
}
|
|
31
|
+
if (node.is_file !== undefined) {
|
|
32
|
+
existing.is_file = node.is_file;
|
|
33
|
+
}
|
|
34
|
+
if (node.children && node.children.length > 0) {
|
|
35
|
+
existing.children = mergeFolderNodes(existing.children || [], node.children);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
map.set(node.name, cloneNode(node));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return Array.from(map.values());
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Merges variables across multiple templates.
|
|
46
|
+
* Variables with the same name are deduplicated; later templates override default values.
|
|
47
|
+
*/
|
|
48
|
+
export function mergeVariables(templates) {
|
|
49
|
+
const varMap = new Map();
|
|
50
|
+
for (const { template } of templates) {
|
|
51
|
+
if (!template.variables)
|
|
52
|
+
continue;
|
|
53
|
+
for (const v of template.variables) {
|
|
54
|
+
if (varMap.has(v.name)) {
|
|
55
|
+
const existing = varMap.get(v.name);
|
|
56
|
+
varMap.set(v.name, {
|
|
57
|
+
name: v.name,
|
|
58
|
+
prompt: v.prompt || existing.prompt,
|
|
59
|
+
default: v.default !== undefined ? v.default : existing.default,
|
|
60
|
+
required: v.required !== undefined ? v.required : existing.required
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
varMap.set(v.name, { ...v });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Array.from(varMap.values());
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Checks if a given destination path corresponds to a root-level readme.md file.
|
|
72
|
+
*/
|
|
73
|
+
export function isRootReadme(filePath) {
|
|
74
|
+
const norm = sanitizePath(filePath).replace(/\\/g, '/');
|
|
75
|
+
const parts = norm.split('/').filter(Boolean);
|
|
76
|
+
return parts.length === 1 && /^readme\.md$/i.test(parts[0]);
|
|
77
|
+
}
|
|
8
78
|
/**
|
|
9
79
|
* Scan parent directories for .env files and parse their variables.
|
|
10
|
-
* Returns a map of variable names to their values, supporting:
|
|
11
|
-
* - KEY=VALUE format
|
|
12
|
-
* - KEY="VALUE with spaces" format
|
|
13
|
-
* - KEY='VALUE with spaces' format
|
|
14
|
-
* - Comments (lines starting with #)
|
|
15
|
-
* - Empty lines
|
|
16
80
|
*/
|
|
17
81
|
function scanEnvForVariables(targetPath) {
|
|
18
82
|
const envVars = {};
|
|
19
83
|
let currentDir = path.resolve(targetPath);
|
|
20
|
-
// Scan up to 5 parent directories for .env files
|
|
21
84
|
const maxDepth = 5;
|
|
22
85
|
for (let depth = 0; depth < maxDepth; depth++) {
|
|
23
86
|
const envPath = path.join(currentDir, '.env');
|
|
@@ -27,16 +90,13 @@ function scanEnvForVariables(targetPath) {
|
|
|
27
90
|
const lines = content.split('\n');
|
|
28
91
|
for (const line of lines) {
|
|
29
92
|
const trimmed = line.trim();
|
|
30
|
-
// Skip empty lines and comments
|
|
31
93
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
32
94
|
continue;
|
|
33
95
|
}
|
|
34
|
-
// Match KEY=VALUE patterns
|
|
35
96
|
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
|
|
36
97
|
if (match) {
|
|
37
98
|
const key = match[1];
|
|
38
99
|
let value = match[2];
|
|
39
|
-
// Remove surrounding quotes if present
|
|
40
100
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
41
101
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
42
102
|
value = value.slice(1, -1);
|
|
@@ -46,78 +106,187 @@ function scanEnvForVariables(targetPath) {
|
|
|
46
106
|
}
|
|
47
107
|
}
|
|
48
108
|
catch (err) {
|
|
49
|
-
// Silently skip unreadable .env files
|
|
50
109
|
continue;
|
|
51
110
|
}
|
|
52
111
|
}
|
|
53
|
-
// Move to parent directory
|
|
54
112
|
const parentDir = path.dirname(currentDir);
|
|
55
113
|
if (parentDir === currentDir) {
|
|
56
|
-
// Reached filesystem root
|
|
57
114
|
break;
|
|
58
115
|
}
|
|
59
116
|
currentDir = parentDir;
|
|
60
117
|
}
|
|
61
118
|
return envVars;
|
|
62
119
|
}
|
|
63
|
-
export async function init(
|
|
120
|
+
export async function init(targetOrArgs, destPathOrOptions, optionsOrUndefined) {
|
|
64
121
|
const config = loadConfig();
|
|
65
|
-
let
|
|
66
|
-
let dest
|
|
67
|
-
let
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
try {
|
|
75
|
-
const fileContent = fs.readFileSync(options.file, 'utf-8');
|
|
76
|
-
template = JSON.parse(fileContent);
|
|
77
|
-
}
|
|
78
|
-
catch (e) {
|
|
79
|
-
console.error(chalk.red(`Error: Failed to read/parse template file "${options.file}": ${e.message}`));
|
|
80
|
-
process.exit(1);
|
|
122
|
+
let rawTemplates = [];
|
|
123
|
+
let dest;
|
|
124
|
+
let options = {};
|
|
125
|
+
if (Array.isArray(targetOrArgs)) {
|
|
126
|
+
options = destPathOrOptions || {};
|
|
127
|
+
if (targetOrArgs.length === 0) {
|
|
128
|
+
rawTemplates = [];
|
|
129
|
+
dest = undefined;
|
|
81
130
|
}
|
|
82
|
-
if (
|
|
83
|
-
|
|
131
|
+
else if (targetOrArgs.length === 1) {
|
|
132
|
+
if (options.file) {
|
|
133
|
+
rawTemplates = [options.file];
|
|
134
|
+
dest = targetOrArgs[0];
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
rawTemplates = [targetOrArgs[0]];
|
|
138
|
+
dest = undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
if (options.file) {
|
|
143
|
+
rawTemplates = [options.file, ...targetOrArgs.slice(0, -1)];
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
rawTemplates = targetOrArgs.slice(0, -1);
|
|
147
|
+
}
|
|
148
|
+
dest = targetOrArgs[targetOrArgs.length - 1];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (typeof targetOrArgs === 'string') {
|
|
152
|
+
if (typeof destPathOrOptions === 'string') {
|
|
153
|
+
dest = destPathOrOptions;
|
|
154
|
+
options = optionsOrUndefined || {};
|
|
155
|
+
}
|
|
156
|
+
else if (destPathOrOptions !== undefined) {
|
|
157
|
+
dest = undefined;
|
|
158
|
+
options = destPathOrOptions || {};
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
dest = undefined;
|
|
162
|
+
options = optionsOrUndefined || {};
|
|
163
|
+
}
|
|
164
|
+
if (options.file && !dest) {
|
|
165
|
+
dest = targetOrArgs;
|
|
166
|
+
rawTemplates = [options.file];
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
rawTemplates = options.file ? [options.file, targetOrArgs] : [targetOrArgs];
|
|
84
170
|
}
|
|
85
171
|
}
|
|
86
172
|
else {
|
|
87
|
-
//
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
173
|
+
// targetOrArgs is undefined
|
|
174
|
+
if (typeof destPathOrOptions === 'string') {
|
|
175
|
+
dest = destPathOrOptions;
|
|
176
|
+
options = optionsOrUndefined || {};
|
|
177
|
+
}
|
|
178
|
+
else if (destPathOrOptions !== undefined) {
|
|
179
|
+
dest = undefined;
|
|
180
|
+
options = destPathOrOptions || {};
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
dest = undefined;
|
|
184
|
+
options = optionsOrUndefined || {};
|
|
185
|
+
}
|
|
186
|
+
if (options.file) {
|
|
187
|
+
rawTemplates = [options.file];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Interactive selection if no templates specified
|
|
191
|
+
if (rawTemplates.length === 0) {
|
|
192
|
+
const names = Object.keys(config.templates);
|
|
193
|
+
if (names.length === 0) {
|
|
194
|
+
const msg = "No templates found. Run 'pt learn <path>' first.";
|
|
195
|
+
if (options.json) {
|
|
196
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
97
197
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
loop: false,
|
|
103
|
-
theme: {
|
|
104
|
-
icon: {
|
|
105
|
-
cursor: chalk.green('[x] ')
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
choices: names.map(n => ({ name: n, value: n }))
|
|
109
|
-
});
|
|
110
|
-
typeName = selected;
|
|
198
|
+
else {
|
|
199
|
+
console.log(chalk.red(msg));
|
|
200
|
+
}
|
|
201
|
+
process.exit(1);
|
|
111
202
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
203
|
+
if (options.yes) {
|
|
204
|
+
const msg = "No project type specified and running in non-interactive mode.";
|
|
205
|
+
if (options.json) {
|
|
206
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
console.error(chalk.red(msg));
|
|
210
|
+
}
|
|
115
211
|
process.exit(1);
|
|
116
212
|
}
|
|
213
|
+
const { selected } = await inquirer.prompt({
|
|
214
|
+
type: 'checkbox',
|
|
215
|
+
name: 'selected',
|
|
216
|
+
message: 'Select Project Type(s):',
|
|
217
|
+
loop: false,
|
|
218
|
+
validate: (answer) => (answer.length < 1 ? 'You must choose at least one template.' : true),
|
|
219
|
+
theme: {
|
|
220
|
+
icon: {
|
|
221
|
+
checked: chalk.green('[x] '),
|
|
222
|
+
unchecked: '[ ] '
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
choices: names.map(n => ({ name: n, value: n }))
|
|
226
|
+
});
|
|
227
|
+
rawTemplates = selected;
|
|
117
228
|
}
|
|
229
|
+
// Load each template configuration
|
|
230
|
+
const loadedTemplates = [];
|
|
231
|
+
for (const item of rawTemplates) {
|
|
232
|
+
// Check if item is a local json file path or exists on disk
|
|
233
|
+
if (item.endsWith('.json') || fs.existsSync(item)) {
|
|
234
|
+
try {
|
|
235
|
+
const resolvedPath = path.resolve(item);
|
|
236
|
+
const fileContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
237
|
+
const parsed = JSON.parse(fileContent);
|
|
238
|
+
const name = parsed.name || path.basename(item, path.extname(item));
|
|
239
|
+
if (parsed.templateRoot && !path.isAbsolute(parsed.templateRoot)) {
|
|
240
|
+
parsed.templateRoot = path.resolve(path.dirname(resolvedPath), parsed.templateRoot);
|
|
241
|
+
}
|
|
242
|
+
else if (!parsed.templateRoot) {
|
|
243
|
+
parsed.templateRoot = path.dirname(resolvedPath);
|
|
244
|
+
}
|
|
245
|
+
loadedTemplates.push({
|
|
246
|
+
name,
|
|
247
|
+
template: parsed,
|
|
248
|
+
sourceFile: resolvedPath
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
const msg = `Failed to read/parse template file "${item}": ${e.message}`;
|
|
253
|
+
if (options.json) {
|
|
254
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
258
|
+
}
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const template = config.templates[item];
|
|
264
|
+
if (!template) {
|
|
265
|
+
const msg = `Template "${item}" not found.`;
|
|
266
|
+
if (options.json) {
|
|
267
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
console.error(chalk.red(msg));
|
|
271
|
+
}
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
loadedTemplates.push({
|
|
275
|
+
name: item,
|
|
276
|
+
template: JSON.parse(JSON.stringify(template)) // Clone to prevent mutating config
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Prompt for destination if not provided
|
|
118
281
|
if (!dest) {
|
|
119
282
|
if (options.yes) {
|
|
120
|
-
|
|
283
|
+
const msg = "No destination path specified and running in non-interactive mode.";
|
|
284
|
+
if (options.json) {
|
|
285
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
console.error(chalk.red(msg));
|
|
289
|
+
}
|
|
121
290
|
process.exit(1);
|
|
122
291
|
}
|
|
123
292
|
const { name } = await inquirer.prompt({
|
|
@@ -129,21 +298,33 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
129
298
|
}
|
|
130
299
|
const resolvedDest = path.resolve(dest);
|
|
131
300
|
if (fs.existsSync(resolvedDest) && !options.dryRun) {
|
|
132
|
-
|
|
301
|
+
const msg = `Destination "${resolvedDest}" already exists.`;
|
|
302
|
+
if (options.json) {
|
|
303
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
307
|
+
}
|
|
133
308
|
process.exit(1);
|
|
134
309
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
310
|
+
const templateNames = loadedTemplates.map(l => l.name);
|
|
311
|
+
const compositeDescription = loadedTemplates.length === 1
|
|
312
|
+
? (loadedTemplates[0].template.description || '')
|
|
313
|
+
: loadedTemplates.map(l => `${l.name}: ${l.template.description || ''}`).join('; ');
|
|
314
|
+
if (!options.json) {
|
|
315
|
+
if (options.dryRun) {
|
|
316
|
+
console.log(chalk.yellow(`\n[DRY RUN] Initializing project "${compositeDescription}" at: ${resolvedDest}`));
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
console.log(chalk.cyan(`\nInitializing project "${compositeDescription}" at: ${resolvedDest}`));
|
|
320
|
+
}
|
|
140
321
|
}
|
|
141
|
-
//
|
|
322
|
+
// Merge Variables
|
|
323
|
+
const mergedVarsDef = mergeVariables(loadedTemplates);
|
|
142
324
|
let variables = {};
|
|
143
|
-
if (
|
|
325
|
+
if (mergedVarsDef.length > 0) {
|
|
144
326
|
// Scan parent directories for .env files and pre-fill variables
|
|
145
327
|
const envVars = scanEnvForVariables(resolvedDest);
|
|
146
|
-
// Merge .env variables into variables (with lower priority than --vars)
|
|
147
328
|
if (Object.keys(envVars).length > 0) {
|
|
148
329
|
for (const [key, value] of Object.entries(envVars)) {
|
|
149
330
|
if (!variables[key]) {
|
|
@@ -152,7 +333,6 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
152
333
|
}
|
|
153
334
|
}
|
|
154
335
|
if (options.vars) {
|
|
155
|
-
// Parse --vars "key=val,key2=val2"
|
|
156
336
|
const pairs = options.vars.split(',').map((p) => p.trim());
|
|
157
337
|
for (const pair of pairs) {
|
|
158
338
|
const [k, ...v] = pair.split('=');
|
|
@@ -162,8 +342,7 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
162
342
|
}
|
|
163
343
|
}
|
|
164
344
|
if (!options.yes) {
|
|
165
|
-
|
|
166
|
-
for (const v of template.variables) {
|
|
345
|
+
for (const v of mergedVarsDef) {
|
|
167
346
|
if (!variables[v.name]) {
|
|
168
347
|
const answer = await inquirer.prompt({
|
|
169
348
|
type: 'input',
|
|
@@ -176,11 +355,16 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
176
355
|
}
|
|
177
356
|
}
|
|
178
357
|
else {
|
|
179
|
-
|
|
180
|
-
for (const v of template.variables) {
|
|
358
|
+
for (const v of mergedVarsDef) {
|
|
181
359
|
if (!variables[v.name]) {
|
|
182
360
|
if (v.required) {
|
|
183
|
-
|
|
361
|
+
const msg = `Variable "${v.name}" is required but was not provided in non-interactive mode. Use --vars ${v.name}=value`;
|
|
362
|
+
if (options.json) {
|
|
363
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
367
|
+
}
|
|
184
368
|
process.exit(1);
|
|
185
369
|
}
|
|
186
370
|
else {
|
|
@@ -190,135 +374,238 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
190
374
|
}
|
|
191
375
|
}
|
|
192
376
|
}
|
|
193
|
-
// 1. Create structure
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
console.warn(chalk.gray("Folder structure created, but files/boilerplate will be skipped."));
|
|
377
|
+
// 1. Create structure (deep merge folders across all templates)
|
|
378
|
+
let mergedFolders = [];
|
|
379
|
+
for (const { template } of loadedTemplates) {
|
|
380
|
+
if (template.folders) {
|
|
381
|
+
mergedFolders = mergeFolderNodes(mergedFolders, template.folders);
|
|
382
|
+
}
|
|
200
383
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
384
|
+
createStructure(resolvedDest, mergedFolders, options.dryRun, options.json);
|
|
385
|
+
// 2. Readme renaming logic
|
|
386
|
+
const templatesWithReadme = [];
|
|
387
|
+
for (const lt of loadedTemplates) {
|
|
388
|
+
const hasReadme = (lt.template.copy_files || []).some(cf => isRootReadme(cf.dest || cf.src));
|
|
389
|
+
if (hasReadme) {
|
|
390
|
+
templatesWithReadme.push(lt);
|
|
391
|
+
}
|
|
208
392
|
}
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const { substituteVariables } = await import('../substitute.js');
|
|
228
|
-
fileContent = substituteVariables(fileContent, variables);
|
|
393
|
+
const createdReadmes = [];
|
|
394
|
+
if (templatesWithReadme.length > 1) {
|
|
395
|
+
// Multiple templates have a root readme -> rename based on origin template name while preserving case
|
|
396
|
+
for (const lt of templatesWithReadme) {
|
|
397
|
+
for (const cf of lt.template.copy_files || []) {
|
|
398
|
+
const targetDest = cf.dest || cf.src;
|
|
399
|
+
if (isRootReadme(targetDest)) {
|
|
400
|
+
const baseName = path.basename(targetDest);
|
|
401
|
+
const match = baseName.match(/^(readme)(.*)(\.md)$/i);
|
|
402
|
+
let newDest;
|
|
403
|
+
if (match) {
|
|
404
|
+
newDest = `${match[1]}${match[2]}_${lt.name}${match[3]}`;
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
newDest = `readme_${lt.name}.md`;
|
|
408
|
+
}
|
|
409
|
+
cf.dest = newDest;
|
|
410
|
+
createdReadmes.push(newDest);
|
|
229
411
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else if (templatesWithReadme.length === 1) {
|
|
416
|
+
// Exactly one template has a root readme -> keep standard name
|
|
417
|
+
for (const cf of templatesWithReadme[0].template.copy_files || []) {
|
|
418
|
+
const targetDest = cf.dest || cf.src;
|
|
419
|
+
if (isRootReadme(targetDest)) {
|
|
420
|
+
createdReadmes.push(targetDest);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// 3. Process copy_files for each template
|
|
425
|
+
const collisionMode = options.collision || 'overwrite';
|
|
426
|
+
for (const lt of loadedTemplates) {
|
|
427
|
+
const template = lt.template;
|
|
428
|
+
const templateRootExists = template.templateRoot && fs.existsSync(template.templateRoot);
|
|
429
|
+
if (template.templateRoot && !templateRootExists) {
|
|
430
|
+
if (!options.json) {
|
|
431
|
+
console.warn(chalk.yellow(`\nWarning: Template source directory not found: ${template.templateRoot}`));
|
|
432
|
+
console.warn(chalk.gray("Folder structure created, but files/boilerplate will be skipped."));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (template.copy_files && templateRootExists) {
|
|
436
|
+
if (!options.json) {
|
|
437
|
+
if (options.dryRun)
|
|
438
|
+
console.log(chalk.yellow(`[DRY RUN] Processing copy_files for ${lt.name}...`));
|
|
439
|
+
else
|
|
440
|
+
console.log(chalk.cyan(`Processing copy_files for ${lt.name}...`));
|
|
441
|
+
}
|
|
442
|
+
await processCopyFiles(template.templateRoot, resolvedDest, template, variables, options.dryRun, collisionMode, options.json);
|
|
443
|
+
}
|
|
444
|
+
// 4. Process post_copy (executable scripts)
|
|
445
|
+
if (template.post_copy && templateRootExists) {
|
|
446
|
+
if (!options.json) {
|
|
447
|
+
if (options.dryRun)
|
|
448
|
+
console.log(chalk.yellow(`[DRY RUN] Processing post_copy for ${lt.name}...`));
|
|
449
|
+
else
|
|
450
|
+
console.log(chalk.cyan(`Processing post_copy for ${lt.name}...`));
|
|
451
|
+
}
|
|
452
|
+
for (const file of template.post_copy) {
|
|
453
|
+
const srcPath = path.join(template.templateRoot, file.src);
|
|
454
|
+
const destPath = path.join(resolvedDest, sanitizePath(file.dest || file.src));
|
|
455
|
+
if (fs.existsSync(srcPath)) {
|
|
456
|
+
if (options.dryRun) {
|
|
457
|
+
if (!options.json) {
|
|
458
|
+
console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
|
|
459
|
+
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
460
|
+
}
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (collisionMode === 'newest' && fs.existsSync(destPath)) {
|
|
464
|
+
const destStat = fs.statSync(destPath);
|
|
465
|
+
const srcStat = fs.statSync(srcPath);
|
|
466
|
+
if (destStat.mtimeMs > srcStat.mtimeMs) {
|
|
467
|
+
if (!options.json) {
|
|
468
|
+
console.log(chalk.yellow(` [COLLISION] Destination is newer, keeping ${file.dest || file.src}`));
|
|
469
|
+
}
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
let fileContent = fs.readFileSync(srcPath, 'utf-8');
|
|
474
|
+
if (mergedVarsDef.length > 0) {
|
|
475
|
+
fileContent = substituteVariables(fileContent, variables);
|
|
476
|
+
}
|
|
477
|
+
const destDir = path.dirname(destPath);
|
|
478
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
479
|
+
fs.writeFileSync(destPath, fileContent);
|
|
480
|
+
try {
|
|
481
|
+
const srcStat = fs.statSync(srcPath);
|
|
482
|
+
fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
|
|
483
|
+
}
|
|
484
|
+
catch (e) { }
|
|
485
|
+
if (!options.json)
|
|
486
|
+
console.log(chalk.green(" ✓ " + (file.dest || file.src)));
|
|
238
487
|
}
|
|
239
|
-
|
|
240
|
-
|
|
488
|
+
else if (!options.json) {
|
|
489
|
+
console.warn(chalk.yellow(" ! " + file.src + " not found, skipping"));
|
|
241
490
|
}
|
|
242
|
-
console.log(chalk.green(" ✓ " + (file.dest || file.src)));
|
|
243
|
-
}
|
|
244
|
-
else {
|
|
245
|
-
console.warn(chalk.yellow(" ! " + file.src + " not found, skipping"));
|
|
246
491
|
}
|
|
247
492
|
}
|
|
248
493
|
}
|
|
249
|
-
// Write .info.md
|
|
494
|
+
// 5. Write .info.md
|
|
250
495
|
if (!options.dryRun) {
|
|
251
|
-
|
|
496
|
+
let infoContent = '';
|
|
497
|
+
if (loadedTemplates.length === 1) {
|
|
498
|
+
infoContent = `# ${loadedTemplates[0].name}\n\n${loadedTemplates[0].template.description || ''}\n`;
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
infoContent = `# ${templateNames.join(', ')}\n\n`;
|
|
502
|
+
for (const lt of loadedTemplates) {
|
|
503
|
+
infoContent += `## ${lt.name}\n${lt.template.description || ''}\n\n`;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
252
506
|
fs.writeFileSync(path.join(resolvedDest, '.info.md'), infoContent);
|
|
253
507
|
}
|
|
254
|
-
else {
|
|
508
|
+
else if (!options.json) {
|
|
255
509
|
console.log(chalk.gray(` [DRY RUN] Would create .info.md`));
|
|
256
510
|
}
|
|
257
|
-
//
|
|
258
|
-
|
|
511
|
+
// 6. Collect and deduplicate post_config tasks from all templates
|
|
512
|
+
// Key: command|description -> { task, templates: string[], _id: string }
|
|
513
|
+
const taskMap = new Map();
|
|
514
|
+
let taskIdCounter = 0;
|
|
515
|
+
for (const lt of loadedTemplates) {
|
|
516
|
+
if (lt.template.post_config) {
|
|
517
|
+
for (const t of lt.template.post_config) {
|
|
518
|
+
if (!t.type || t.type === lt.name) {
|
|
519
|
+
const key = `${t.command || t.script || ''}|${t.description || ''}`;
|
|
520
|
+
if (taskMap.has(key)) {
|
|
521
|
+
taskMap.get(key).templates.push(lt.name);
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
taskMap.set(key, {
|
|
525
|
+
...t,
|
|
526
|
+
templates: [lt.name],
|
|
527
|
+
_id: `task_${taskIdCounter++}`
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const allTasks = Array.from(taskMap.values());
|
|
259
535
|
if (allTasks.length > 0 && !options.skipPostConfig) {
|
|
260
|
-
// SECURITY CHECK: Validate template safety
|
|
536
|
+
// SECURITY CHECK: Validate template safety (aggregate across all templates)
|
|
261
537
|
const { validateTemplateSecurity } = await import('../safety.js');
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
538
|
+
// Collect all errors and warnings from all templates first
|
|
539
|
+
const allErrors = [];
|
|
540
|
+
const allWarnings = [];
|
|
541
|
+
for (const lt of loadedTemplates) {
|
|
542
|
+
const { valid, errors, warnings } = validateTemplateSecurity(lt.template);
|
|
265
543
|
for (const err of errors) {
|
|
266
|
-
|
|
544
|
+
allErrors.push({ template: lt.name, error: err });
|
|
545
|
+
}
|
|
546
|
+
for (const warn of warnings) {
|
|
547
|
+
allWarnings.push({ template: lt.name, warning: warn });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Handle errors - any blocked command aborts everything
|
|
551
|
+
if (allErrors.length > 0) {
|
|
552
|
+
if (!options.json) {
|
|
553
|
+
console.error(chalk.red(`\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:`));
|
|
554
|
+
for (const { template, error } of allErrors) {
|
|
555
|
+
console.error(chalk.red(` [${template}] ${error}`));
|
|
556
|
+
}
|
|
267
557
|
}
|
|
268
558
|
process.exit(1);
|
|
269
559
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
console.warn(chalk.yellow(
|
|
560
|
+
// Handle warnings - single aggregated prompt for all templates
|
|
561
|
+
if (allWarnings.length > 0) {
|
|
562
|
+
if (!options.json) {
|
|
563
|
+
console.warn(chalk.yellow(`\n⚠️ SECURITY WARNING: Post-config tasks contain dangerous commands:`));
|
|
564
|
+
for (const { template, warning } of allWarnings) {
|
|
565
|
+
console.warn(chalk.yellow(` [${template}] ${warning}`));
|
|
566
|
+
}
|
|
274
567
|
}
|
|
275
568
|
if (!options.yes) {
|
|
276
569
|
const { proceed } = await inquirer.prompt({
|
|
277
570
|
type: 'confirm',
|
|
278
571
|
name: 'proceed',
|
|
279
|
-
message: chalk.red(
|
|
572
|
+
message: chalk.red(`Security warnings found in ${new Set(allWarnings.map(w => w.template)).size} template(s). Run post-config tasks anyway?`),
|
|
280
573
|
default: false
|
|
281
574
|
});
|
|
282
575
|
if (!proceed) {
|
|
283
|
-
|
|
576
|
+
if (!options.json)
|
|
577
|
+
console.log(chalk.yellow("Post-config tasks aborted by user."));
|
|
284
578
|
return;
|
|
285
579
|
}
|
|
286
580
|
}
|
|
287
|
-
else {
|
|
581
|
+
else if (!options.json) {
|
|
288
582
|
console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
|
|
289
583
|
}
|
|
290
584
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
for (const t of allTasks) {
|
|
302
|
-
const desc = t.description ? ` (${t.description})` : '';
|
|
303
|
-
console.log(chalk.gray(` [template] - ${t.command || `./${t.script}`}${desc}`));
|
|
585
|
+
let selectedTaskIds = [];
|
|
586
|
+
if (options.dryRun) {
|
|
587
|
+
selectedTaskIds = allTasks.map(t => t._id);
|
|
588
|
+
if (!options.json) {
|
|
589
|
+
console.log(chalk.yellow(`\n[DRY RUN] Applicable post-config tasks:`));
|
|
590
|
+
for (const t of allTasks) {
|
|
591
|
+
const desc = t.description ? ` (${t.description})` : '';
|
|
592
|
+
const templatesNote = t.templates.length > 1 ? ` [${t.templates.join(', ')}]` : ` [${t.templates[0]}]`;
|
|
593
|
+
console.log(chalk.gray(` ${t.command || `./${t.script}`}${desc}${templatesNote}`));
|
|
594
|
+
}
|
|
304
595
|
}
|
|
305
596
|
}
|
|
306
597
|
else if (options.yes) {
|
|
307
|
-
|
|
308
|
-
selectedTaskNames = allTasks.map(t => t.command || `./${t.script}` || '');
|
|
309
|
-
}
|
|
310
|
-
else if (allTasks.length === 0) {
|
|
311
|
-
selectedTaskNames = [];
|
|
598
|
+
selectedTaskIds = allTasks.map(t => t._id);
|
|
312
599
|
}
|
|
313
600
|
else {
|
|
314
|
-
// Checkbox prompt
|
|
315
601
|
const choices = [];
|
|
316
602
|
for (const t of allTasks) {
|
|
317
603
|
const cmd = t.command || `./${t.script}` || '(no command)';
|
|
318
604
|
const desc = t.description ? ` (${t.description})` : '';
|
|
605
|
+
const templatesNote = t.templates.length > 1 ? ` [${t.templates.join(', ')}]` : ` [${t.templates[0]}]`;
|
|
319
606
|
choices.push({
|
|
320
|
-
name: `${cmd}${desc}`,
|
|
321
|
-
value:
|
|
607
|
+
name: `${cmd}${desc}${templatesNote}`,
|
|
608
|
+
value: t._id,
|
|
322
609
|
checked: true
|
|
323
610
|
});
|
|
324
611
|
}
|
|
@@ -335,14 +622,12 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
335
622
|
},
|
|
336
623
|
choices
|
|
337
624
|
});
|
|
338
|
-
|
|
625
|
+
selectedTaskIds = response.selected || [];
|
|
339
626
|
}
|
|
340
|
-
|
|
341
|
-
if (selectedTaskNames.length > 0 && !options.dryRun) {
|
|
627
|
+
if (selectedTaskIds.length > 0 && !options.dryRun) {
|
|
342
628
|
let bashContent = '#!/bin/bash\n# Auto-generated post_config script\n\n';
|
|
343
629
|
let batContent = '@echo off\n:: Auto-generated post_config script\n\n';
|
|
344
630
|
for (const t of allTasks) {
|
|
345
|
-
// Determine the actual command/script to use
|
|
346
631
|
let cmd = '';
|
|
347
632
|
if (t.command) {
|
|
348
633
|
cmd = t.command;
|
|
@@ -350,12 +635,10 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
350
635
|
else if (t.script) {
|
|
351
636
|
cmd = `./${t.script}`;
|
|
352
637
|
}
|
|
353
|
-
|
|
354
|
-
const taskKey = t.command || (t.script ? `./${t.script}` : '');
|
|
355
|
-
if (selectedTaskNames.includes(taskKey)) {
|
|
638
|
+
if (selectedTaskIds.includes(t._id)) {
|
|
356
639
|
if (cmd) {
|
|
357
|
-
bashContent += `echo "Running: ${t.description ||
|
|
358
|
-
batContent += `echo Running: ${t.description ||
|
|
640
|
+
bashContent += `echo "Running: ${t.description || cmd}"\n${cmd}\n`;
|
|
641
|
+
batContent += `echo Running: ${t.description || cmd}\n${cmd}\n`;
|
|
359
642
|
}
|
|
360
643
|
}
|
|
361
644
|
}
|
|
@@ -365,49 +648,61 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
365
648
|
}
|
|
366
649
|
catch (e) { }
|
|
367
650
|
fs.writeFileSync(path.join(resolvedDest, 'post_config.bat'), batContent);
|
|
368
|
-
|
|
369
|
-
|
|
651
|
+
if (!options.json)
|
|
652
|
+
console.log(chalk.cyan("\nExecuting post-config tasks..."));
|
|
370
653
|
try {
|
|
371
654
|
const scriptCmd = process.platform === 'win32' ? 'post_config.bat' : './post_config.sh';
|
|
372
655
|
execSync(scriptCmd, {
|
|
373
656
|
cwd: resolvedDest,
|
|
374
|
-
stdio: 'inherit'
|
|
657
|
+
stdio: options.json ? 'ignore' : 'inherit'
|
|
375
658
|
});
|
|
376
659
|
}
|
|
377
660
|
catch (e) {
|
|
378
|
-
|
|
661
|
+
if (!options.json)
|
|
662
|
+
console.error(chalk.red("\nError: Some post-config tasks failed. Check the output above."));
|
|
379
663
|
}
|
|
380
664
|
}
|
|
381
665
|
}
|
|
382
|
-
if (options.
|
|
666
|
+
if (options.json) {
|
|
667
|
+
const result = {
|
|
668
|
+
status: 'success',
|
|
669
|
+
dryRun: !!options.dryRun,
|
|
670
|
+
dest: resolvedDest,
|
|
671
|
+
templates: templateNames,
|
|
672
|
+
variables,
|
|
673
|
+
readmes: createdReadmes
|
|
674
|
+
};
|
|
675
|
+
console.log(JSON.stringify(result, null, 2));
|
|
676
|
+
}
|
|
677
|
+
else if (options.dryRun) {
|
|
383
678
|
console.log(chalk.yellow(`\n[DRY RUN] Project initialization preview complete.`));
|
|
384
679
|
}
|
|
385
680
|
else {
|
|
386
681
|
console.log(chalk.green(`\n✓ Project created successfully.`));
|
|
387
682
|
}
|
|
388
683
|
}
|
|
389
|
-
function createStructure(dirPath, folders, dryRun = false) {
|
|
684
|
+
function createStructure(dirPath, folders, dryRun = false, silent = false) {
|
|
390
685
|
for (const folder of folders) {
|
|
391
686
|
const fullDirPath = path.join(dirPath, sanitizePath(folder.name));
|
|
392
687
|
if (dryRun) {
|
|
393
|
-
|
|
688
|
+
if (!silent)
|
|
689
|
+
console.log(chalk.gray(` [DRY RUN] Would create directory: ${fullDirPath}`));
|
|
394
690
|
}
|
|
395
691
|
else {
|
|
396
692
|
fs.mkdirSync(fullDirPath, { recursive: true });
|
|
397
693
|
}
|
|
398
|
-
// Create .info.md if content exists
|
|
399
694
|
if (folder.info) {
|
|
400
695
|
const infoPath = path.join(fullDirPath, '.info.md');
|
|
401
696
|
if (dryRun) {
|
|
402
|
-
|
|
697
|
+
if (!silent)
|
|
698
|
+
console.log(chalk.gray(` [DRY RUN] Would create info file: ${infoPath}`));
|
|
403
699
|
}
|
|
404
700
|
else {
|
|
405
701
|
fs.writeFileSync(infoPath, folder.info);
|
|
406
702
|
}
|
|
407
703
|
}
|
|
408
|
-
// Recurse children
|
|
409
704
|
if (folder.children && folder.children.length > 0) {
|
|
410
|
-
createStructure(fullDirPath, folder.children, dryRun);
|
|
705
|
+
createStructure(fullDirPath, folder.children, dryRun, silent);
|
|
411
706
|
}
|
|
412
707
|
}
|
|
413
708
|
}
|